196 lines
5.7 KiB
TypeScript
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;
|
|
}
|
|
} |