import { StoredUser } from '../repositories/UserRepository'; import type { EmailValidationResult } from '../types/EmailAddress'; import { validateEmail } from '../types/EmailAddress'; import { PasswordHash } from '../value-objects/PasswordHash'; import { UserId } from '../value-objects/UserId'; export interface UserProps { id: UserId; displayName: string; email?: string; passwordHash?: PasswordHash; iracingCustomerId?: string; primaryDriverId?: string; avatarUrl?: string; companyId?: string; } export class User { private readonly id: UserId; private displayName: string; private email: string | undefined; private passwordHash: PasswordHash | undefined; private iracingCustomerId: string | undefined; private primaryDriverId: string | undefined; private avatarUrl: string | undefined; private companyId: string | undefined; private constructor(props: UserProps) { this.validateDisplayName(props.displayName); this.id = props.id; this.displayName = this.formatDisplayName(props.displayName); this.email = props.email; this.passwordHash = props.passwordHash; this.iracingCustomerId = props.iracingCustomerId; this.primaryDriverId = props.primaryDriverId; this.avatarUrl = props.avatarUrl; this.companyId = props.companyId; } private validateDisplayName(displayName: string): void { const trimmed = displayName?.trim(); if (!trimmed) { throw new Error('Display name cannot be empty'); } if (trimmed.length < 2) { throw new Error('Name must be at least 2 characters long'); } if (trimmed.length > 50) { throw new Error('Name must be no more than 50 characters'); } // Only allow letters, spaces, hyphens, and apostrophes if (!/^[A-Za-z\s\-']+$/.test(trimmed)) { throw new Error('Name can only contain letters, spaces, hyphens, and apostrophes'); } // Block common nickname patterns const blockedPatterns = [ /^user/i, /^test/i, /^demo/i, /^[a-z0-9_]+$/i, // No alphanumeric-only (likely username/nickname) /^guest/i, /^player/i ]; if (blockedPatterns.some(pattern => pattern.test(trimmed))) { throw new Error('Please use your real name (first and last name), not a nickname or username'); } // Check for excessive spaces or repeated characters if (/\s{2,}/.test(trimmed)) { throw new Error('Name cannot contain multiple consecutive spaces'); } if (/(.)\1{2,}/.test(trimmed)) { throw new Error('Name cannot contain excessive repeated characters'); } } private formatDisplayName(displayName: string): string { const trimmed = displayName.trim(); // Capitalize first letter of each word return trimmed.replace(/\b\w/g, char => char.toUpperCase()); } public static create(props: UserProps): User { if (props.email) { const result: EmailValidationResult = validateEmail(props.email); if (!result.success) { throw new Error(result.error); } return new User({ ...props, email: result.email, }); } return new User(props); } public static rehydrate(props: { id: string; displayName: string; email?: string; passwordHash?: PasswordHash; iracingCustomerId?: string; primaryDriverId?: string; avatarUrl?: string; companyId?: string; }): User { const email = props.email !== undefined ? (() => { const result: EmailValidationResult = validateEmail(props.email); if (!result.success) { throw new Error(result.error); } return result.email; })() : undefined; return new User({ id: UserId.fromString(props.id), displayName: props.displayName, ...(email !== undefined ? { email } : {}), ...(props.passwordHash !== undefined ? { passwordHash: props.passwordHash } : {}), ...(props.iracingCustomerId !== undefined ? { iracingCustomerId: props.iracingCustomerId } : {}), ...(props.primaryDriverId !== undefined ? { primaryDriverId: props.primaryDriverId } : {}), ...(props.avatarUrl !== undefined ? { avatarUrl: props.avatarUrl } : {}), ...(props.companyId !== undefined ? { companyId: props.companyId } : {}), }); } public static fromStored(stored: StoredUser): User { const passwordHash = stored.passwordHash ? PasswordHash.fromHash(stored.passwordHash) : undefined; const userProps: UserProps = { id: UserId.fromString(stored.id), displayName: stored.displayName, ...(stored.email !== undefined ? { email: stored.email } : {}), ...(passwordHash !== undefined ? { passwordHash } : {}), ...(stored.primaryDriverId !== undefined ? { primaryDriverId: stored.primaryDriverId } : {}), ...(stored.companyId !== undefined ? { companyId: stored.companyId } : {}), }; return new User(userProps); } public getId(): UserId { return this.id; } public getDisplayName(): string { return this.displayName; } public getEmail(): string | undefined { return this.email; } public getPasswordHash(): PasswordHash | undefined { return this.passwordHash; } public getIracingCustomerId(): string | undefined { return this.iracingCustomerId; } public getPrimaryDriverId(): string | undefined { return this.primaryDriverId; } public getAvatarUrl(): string | undefined { return this.avatarUrl; } public getCompanyId(): string | undefined { return this.companyId; } /** * Update display name - NOT ALLOWED after initial creation * This method will always throw an error to enforce immutability */ public updateDisplayName(): void { throw new Error('Display name cannot be changed after account creation. Please contact support if you need to update your name.'); } /** * Check if this user was created with a valid real name * Used to verify immutability for existing users */ public hasImmutableName(): boolean { // All users created through proper channels have immutable names return true; } /** * Set company ID (for linking user to company during sponsor signup) * This is only allowed during initial creation via rehydrate/fromStored * For runtime updates, a separate method would be needed */ public setCompanyId(companyId: string): void { this.companyId = companyId; } }