import { type UserRole, type KYCStatus } from '@prisma/client'; import { AggregateRoot } from '@modules/shared'; import { UserDeactivatedEvent } from '../events/user-deactivated.event'; import { UserKycUpdatedEvent } from '../events/user-kyc-updated.event'; import { UserRegisteredEvent } from '../events/user-registered.event'; import { type Email } from '../value-objects/email.vo'; import { type HashedPassword } from '../value-objects/hashed-password.vo'; import { type Phone } from '../value-objects/phone.vo'; export interface UserProps { email: Email | null; phone: Phone; passwordHash: HashedPassword | null; fullName: string; avatarUrl: string | null; role: UserRole; kycStatus: KYCStatus; kycData: unknown; isActive: boolean; deletedAt: Date | null; totpSecret: string | null; totpEnabled: boolean; totpBackupCodes: string[]; totpEnabledAt: Date | null; mfaGraceStartedAt: Date | null; mfaLastVerifiedAt: Date | null; } export class UserEntity extends AggregateRoot { private _email: Email | null; private _phone: Phone; private _passwordHash: HashedPassword | null; private _fullName: string; private _avatarUrl: string | null; private _role: UserRole; private _kycStatus: KYCStatus; private _kycData: unknown; private _isActive: boolean; private _deletedAt: Date | null; private _totpSecret: string | null; private _totpEnabled: boolean; private _totpBackupCodes: string[]; private _totpEnabledAt: Date | null; private _mfaGraceStartedAt: Date | null; private _mfaLastVerifiedAt: Date | null; constructor(id: string, props: UserProps, createdAt?: Date, updatedAt?: Date) { super(id, createdAt, updatedAt); this._email = props.email; this._phone = props.phone; this._passwordHash = props.passwordHash; this._fullName = props.fullName; this._avatarUrl = props.avatarUrl; this._role = props.role; this._kycStatus = props.kycStatus; this._kycData = props.kycData; this._isActive = props.isActive; this._deletedAt = props.deletedAt; this._totpSecret = props.totpSecret; this._totpEnabled = props.totpEnabled; this._totpBackupCodes = props.totpBackupCodes; this._totpEnabledAt = props.totpEnabledAt; this._mfaGraceStartedAt = props.mfaGraceStartedAt; this._mfaLastVerifiedAt = props.mfaLastVerifiedAt; } get email(): Email | null { return this._email; } get phone(): Phone { return this._phone; } get passwordHash(): HashedPassword | null { return this._passwordHash; } get fullName(): string { return this._fullName; } get avatarUrl(): string | null { return this._avatarUrl; } get role(): UserRole { return this._role; } get kycStatus(): KYCStatus { return this._kycStatus; } get kycData(): unknown { return this._kycData; } get isActive(): boolean { return this._isActive; } get deletedAt(): Date | null { return this._deletedAt; } get totpSecret(): string | null { return this._totpSecret; } get totpEnabled(): boolean { return this._totpEnabled; } get totpBackupCodes(): string[] { return this._totpBackupCodes; } get totpEnabledAt(): Date | null { return this._totpEnabledAt; } get mfaGraceStartedAt(): Date | null { return this._mfaGraceStartedAt; } get mfaLastVerifiedAt(): Date | null { return this._mfaLastVerifiedAt; } static createNew( id: string, phone: Phone, fullName: string, passwordHash: HashedPassword, email?: Email, role: UserRole = 'BUYER', ): UserEntity { const user = new UserEntity(id, { email: email ?? null, phone, passwordHash, fullName, avatarUrl: null, role, kycStatus: 'NONE', kycData: null, isActive: true, deletedAt: null, totpSecret: null, totpEnabled: false, totpBackupCodes: [], totpEnabledAt: null, mfaGraceStartedAt: null, mfaLastVerifiedAt: null, }); user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role)); return user; } /** * Create a passwordless user (e.g. via Phone-OTP login auto-register). * `passwordHash` is null so password login is not possible until the user * sets one via the password-reset / profile flow. A fullName fallback is * used since OTP signup does not collect a name. */ static createPasswordless( id: string, phone: Phone, fullName?: string, role: UserRole = 'BUYER', ): UserEntity { const displayName = fullName && fullName.trim().length > 0 ? fullName : `Người dùng ${phone.value.slice(-4)}`; const user = new UserEntity(id, { email: null, phone, passwordHash: null, fullName: displayName, avatarUrl: null, role, kycStatus: 'NONE', kycData: null, isActive: true, deletedAt: null, totpSecret: null, totpEnabled: false, totpBackupCodes: [], totpEnabledAt: null, mfaGraceStartedAt: null, mfaLastVerifiedAt: null, }); user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role)); return user; } updateKycStatus(status: KYCStatus, kycData?: unknown): void { const previousStatus = this._kycStatus; this._kycStatus = status; if (kycData !== undefined) this._kycData = kycData; this.updatedAt = new Date(); this.addDomainEvent(new UserKycUpdatedEvent(this.id, status, previousStatus)); } deactivate(): void { this._isActive = false; this.updatedAt = new Date(); this.addDomainEvent(new UserDeactivatedEvent(this.id)); } activate(): void { this._isActive = true; this.updatedAt = new Date(); } enableTotp(secret: string, backupCodes: string[]): void { this._totpSecret = secret; this._totpEnabled = true; this._totpBackupCodes = backupCodes; this._totpEnabledAt = new Date(); this.updatedAt = new Date(); } disableTotp(): void { this._totpSecret = null; this._totpEnabled = false; this._totpBackupCodes = []; this._totpEnabledAt = null; this.updatedAt = new Date(); } consumeBackupCode(index: number): void { this._totpBackupCodes = this._totpBackupCodes.filter((_, i) => i !== index); this.updatedAt = new Date(); } updateProfile(fullName?: string, avatarUrl?: string | null, email?: Email | null): void { if (fullName !== undefined) this._fullName = fullName; if (avatarUrl !== undefined) this._avatarUrl = avatarUrl; if (email !== undefined) this._email = email; this.updatedAt = new Date(); } updatePhone(phone: Phone): void { this._phone = phone; this.updatedAt = new Date(); } changePassword(passwordHash: HashedPassword): void { this._passwordHash = passwordHash; this.updatedAt = new Date(); } }