Change Username
Set or rotate the user's username with availability + reserved checks, a per-user cooldown, and a history record. First-time set has no cooldown.
PATCH /api/v1/users/username โ ๐ Bearer ยท Rate limit: 5 req / hour
Sets or rotates User.username. Format-validates against admin-configurable bounds (site.username_min_length, site.username_max_length, default 3โ30, lowercase alphanumeric + ._-), enforces a per-user cooldown after each change (username.change_cooldown_days, default 30), checks the desired value against active users + the reserved-name list, and writes a UsernameHistory row for audit.
First-time set has no cooldown. If the user has never set a username (User.username = null), the cooldown check is skipped โ they can claim a name on signup-completion flows without waiting.
The error.user.username_cooldown error includes a daysLeft payload param so the UI can render "Try again in 7 days" without re-fetching the change history.
Request
Body โ ChangeUsernameDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
username | string | โ | min 3, max 30, regex ^[a-z0-9._-]+$ | Lowercase only โ server normalizes via .toLowerCase().trim(). The DTO bounds match defaults; admin can widen via site.username_min_length / site.username_max_length. |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | โ | JWT from POST /auth/login |
Response
200 OK โ SuccessOnlyResponseDto
{
"success": true
}| Field | Type | Notes |
|---|---|---|
success | boolean | Always true on 200 |
Errors
| HTTP | code / i18nKey | Payload params | Reason |
|---|---|---|---|
400 | error.user.username_length | { minLen, maxLen } | Outside admin-configured length bounds |
400 | error.user.username_format | โ | Failed regex (must be lowercase alphanumeric + ._-) |
400 | error.user.username_same | โ | The submitted value equals the current User.username (only checked for non-first-time set) |
400 | error.user.username_cooldown | { daysLeft } | Last change is within the cooldown window |
401 | (guard) | โ | Missing / invalid bearer token |
404 | error.user.not_found | โ | Token decoded but user row missing |
409 | error.user.username_taken | โ | Already taken by another user OR matches a reserved name OR concurrent claim race (Prisma P2002) |
429 | (throttle) | โ | Rate limit exceeded (5 req/hour) |
Side effects
- Normalize submitted value (
.toLowerCase().trim()). - Length check against
site.username_min_length/site.username_max_length(admin-managed defaults 3 / 30). - Format check against
^[a-z0-9._-]+$. - Lookup
User; thrownot_foundif missing. - Determine
isFirstSet = (user.username === null). Cooldown checks below skip when first-set. - Reject if value equals current username (
username_same). - Cooldown check โ read
usernameHistory(most recentchangedAt); if withinusername.change_cooldown_days(default 30), throwusername_cooldownwith{ daysLeft }. - Availability check โ
prisma.user.findUnique({ where: { username } })ANDprisma.reservedUsername.findUnique; throwusername_takenif either hits. - Inside one transaction:
- Insert
UsernameHistory { oldUsername, newUsername, changedAt: now() }. - Update
User.username = normalized. - Referral code sync (see section below).
- On Prisma
P2002(concurrent claim race), throwusername_taken.
- Insert
- Audit log:
[username] Changed: <old> โ <new> (user {userId}).
Referral code side-effect
Inside the same transaction that updates User.username, the server may also update the user's ReferralLink.code so the shareable URL stays in sync with the new username. The previous code is preserved as a ReferralLinkAlias row, so anything previously shared as bio.re/ref/<oldcode> keeps resolving to the same ReferralLink and credits the original referrer indefinitely.
Only vanity codes are synced. "Vanity" means ReferralLink.code === oldUsername exactly. If the link's code is a random 8-char slice (assigned at first GET /referral/link because the user hadn't set a username yet, or assigned because an earlier rename hit a collision and the code never got reclaimed), nothing happens โ the existing ReferralLink.code and existing aliases are untouched.
Username change always succeeds; referral sync is best-effort. A collision on the referral side does NOT roll back the username change โ the username update commits and the referral code stays on its old value. The skip is logged server-side at warn level only; no error is returned to the client and the response shape is unchanged.
Algorithm
- Inside the same
prisma.$transactioncallback that wrote theUsernameHistoryrow and updatedUser.username: tx.referralLink.findUnique({ where: { userId } })โ if no link exists, orlink.code !== oldUsername, return (nothing to sync; non-vanity code or first-time username set).- Cross-table collision check for the new username, run as one
Promise.all:tx.referralLink.findFirst({ where: { code: normalized, NOT: { userId } } })โ another user already owns aReferralLinkwith this code.tx.referralLinkAlias.findUnique({ where: { code: normalized } })โ code is reserved by an alias (anyone's previous vanity code).tx.user.findUnique({ where: { referralCode: normalized } })โ code clashes with someone'sUser.referralCode.
- If any of the three hit, log
[username] Referral code sync skipped due to collision: <code> (user <userId>)at warn level and return (username change still commits). - Otherwise:
tx.referralLinkAlias.create({ id, referralLinkId: link.id, code: link.code })โ preserve the old code.tx.referralLink.update({ where: { id: link.id }, data: { code: normalized } })โ repoint the live code.- Log
[username] Referral code synced: <old> โ <new> (alias preserved) for user <userId>.
Implications for clients
- After a vanity-code rename, both
bio.re/ref/<oldcode>andbio.re/ref/<newcode>continue to resolve to the sameReferralLink. Clicks on either URL increment the sameReferralLink.clickscounter โ no double-counting (the alias row carries no counters of its own; it just mapscode โ referralLinkId). GET /referral/linkwill now return the new vanity code; previously copied/printed links remain valid via the alias.- An alias once created is permanent for the life of the
ReferralLink(deleted only byonDelete: Cascadewhen the link itself is deleted). A username can be claimed multiple times across renames, producing one alias per vanity rename. - A username that was previously someone's vanity (now an alias) is not claimable as a new vanity referral code by another user โ but the username change itself is unaffected, since the
User.usernameuniqueness check (step 8 above) only consults theUsertable, notReferralLink/ReferralLinkAlias. The collision only blocks the referral-code repoint.
Code samples
curl -X PATCH https://api.bio.re/api/v1/users/username \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"username": "johndoe"}'type UsernameError = {
code:
| 'error.user.username_length'
| 'error.user.username_format'
| 'error.user.username_same'
| 'error.user.username_cooldown'
| 'error.user.username_taken'
| 'error.user.not_found';
daysLeft?: number; // present when code === 'error.user.username_cooldown'
minLen?: number; // present when code === 'error.user.username_length'
maxLen?: number; // present when code === 'error.user.username_length'
};
async function changeUsername(accessToken: string, username: string): Promise<void> {
const res = await fetch('https://api.bio.re/api/v1/users/username', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ username }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Change username failed'), {
code: json?.error?.code,
daysLeft: json?.error?.daysLeft,
minLen: json?.error?.minLen,
maxLen: json?.error?.maxLen,
});
}
}import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useChangeUsername() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (username: string) => {
const res = await fetch('/api/v1/users/username', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Change username failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
// Cooldown errors carry daysLeft for UI rendering
daysLeft: json?.error?.daysLeft,
});
}
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users', 'profile'] });
qc.invalidateQueries({ queryKey: ['auth', 'me'] });
},
});
}Try it
Authorization
bearer In: header
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
curl -X PATCH "https://loading/api/v1/users/username" \ -H "Content-Type: application/json" \ -d '{ "username": "string" }'{
"success": true
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/user/user.controller.ts | 103โ112 (changeUsername) |
| DTO (request) | apps/api-core/src/modules/user/dto/index.ts | 9โ14 (ChangeUsernameDto) |
| DTO (response) | apps/api-core/src/common/dto/common-response.dto.ts | SuccessOnlyResponseDto |
| Service | apps/api-core/src/modules/user/user.service.ts | 145โ241 (changeUsername); referral sync block 209โ230 |
| Prisma models | packages/prisma/prisma/schema.prisma | User.username, UsernameHistory, ReservedUsername; referral side-effect: ReferralLink 2103โ2119, ReferralLinkAlias 2121โ2130 |
| Live response | MISSING โ to be captured by Lead before publish | โ |
Set User Intent
One-time, post-registration choice โ FAN or CREATOR. Drives the post-signup landing destination and is rejected on second call.
Check Username Availability
Real-time availability probe for the registration / settings form. Public โ no auth required. Returns false on bad format too, so the UI can stop after one round-trip.