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. - On Prisma
P2002(concurrent claim race), throwusername_taken.
- Insert
- Audit log:
[username] Changed: <old> โ <new> (user {userId}).
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โ217 (changeUsername) |
| Prisma models | packages/prisma/prisma/schema.prisma | User.username, UsernameHistory, ReservedUsername |
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.