BIO.RE
User

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

FieldTypeRequiredValidationNotes
usernamestringโœ“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.
HeaderRequiredNotes
Authorization: Bearer <accessToken>โœ“JWT from POST /auth/login

Response

200 OK โ€” SuccessOnlyResponseDto

{
  "success": true
}
FieldTypeNotes
successbooleanAlways true on 200

Errors

HTTPcode / i18nKeyPayload paramsReason
400error.user.username_length{ minLen, maxLen }Outside admin-configured length bounds
400error.user.username_formatโ€”Failed regex (must be lowercase alphanumeric + ._-)
400error.user.username_sameโ€”The submitted value equals the current User.username (only checked for non-first-time set)
400error.user.username_cooldown{ daysLeft }Last change is within the cooldown window
401(guard)โ€”Missing / invalid bearer token
404error.user.not_foundโ€”Token decoded but user row missing
409error.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

  1. Normalize submitted value (.toLowerCase().trim()).
  2. Length check against site.username_min_length / site.username_max_length (admin-managed defaults 3 / 30).
  3. Format check against ^[a-z0-9._-]+$.
  4. Lookup User; throw not_found if missing.
  5. Determine isFirstSet = (user.username === null). Cooldown checks below skip when first-set.
  6. Reject if value equals current username (username_same).
  7. Cooldown check โ€” read usernameHistory (most recent changedAt); if within username.change_cooldown_days (default 30), throw username_cooldown with { daysLeft }.
  8. Availability check โ€” prisma.user.findUnique({ where: { username } }) AND prisma.reservedUsername.findUnique; throw username_taken if either hits.
  9. Inside one transaction:
    • Insert UsernameHistory { oldUsername, newUsername, changedAt: now() }.
    • Update User.username = normalized.
    • On Prisma P2002 (concurrent claim race), throw username_taken.
  10. 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

PATCH
/api/v1/users/username
AuthorizationBearer <token>

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

SourcePathLines
Controllerapps/api-core/src/modules/user/user.controller.ts103โ€“112 (changeUsername)
DTO (request)apps/api-core/src/modules/user/dto/index.ts9โ€“14 (ChangeUsernameDto)
DTO (response)apps/api-core/src/common/dto/common-response.dto.tsSuccessOnlyResponseDto
Serviceapps/api-core/src/modules/user/user.service.ts145โ€“217 (changeUsername)
Prisma modelspackages/prisma/prisma/schema.prismaUser.username, UsernameHistory, ReservedUsername

On this page