Files
gridpilot.gg/core/identity/domain/entities/User.ts
2025-12-31 19:55:43 +01:00

196 lines
5.7 KiB
TypeScript

import type { EmailValidationResult } from '../types/EmailAddress';
import { validateEmail } from '../types/EmailAddress';
import { UserId } from '../value-objects/UserId';
import { PasswordHash } from '../value-objects/PasswordHash';
import { StoredUser } from '../repositories/IUserRepository';
export interface UserProps {
id: UserId;
displayName: string;
email?: string;
passwordHash?: PasswordHash;
iracingCustomerId?: string;
primaryDriverId?: string;
avatarUrl?: 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 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;
}
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;
}): 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 } : {}),
});
}
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 }
: {}),
};
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;
}
/**
* 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;
}
}