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.
    • Referral code sync (see section below).
    • On Prisma P2002 (concurrent claim race), throw username_taken.
  10. 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

  1. Inside the same prisma.$transaction callback that wrote the UsernameHistory row and updated User.username:
  2. tx.referralLink.findUnique({ where: { userId } }) โ€” if no link exists, or link.code !== oldUsername, return (nothing to sync; non-vanity code or first-time username set).
  3. 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 a ReferralLink with 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's User.referralCode.
  4. 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).
  5. 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> and bio.re/ref/<newcode> continue to resolve to the same ReferralLink. Clicks on either URL increment the same ReferralLink.clicks counter โ€” no double-counting (the alias row carries no counters of its own; it just maps code โ†’ referralLinkId).
  • GET /referral/link will 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 by onDelete: Cascade when 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.username uniqueness check (step 8 above) only consults the User table, not ReferralLink / 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

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โ€“241 (changeUsername); referral sync block 209โ€“230
Prisma modelspackages/prisma/prisma/schema.prismaUser.username, UsernameHistory, ReservedUsername; referral side-effect: ReferralLink 2103โ€“2119, ReferralLinkAlias 2121โ€“2130
Live responseMISSING โ€” to be captured by Lead before publishโ€”

On this page