From 7975982fc38420bd5cba52aa36faf0023da3aaea Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 25 Jan 2026 19:32:17 -0800 Subject: [PATCH] feat(user): add user profile and contact management - Add UserProfile entity with immigration-specific fields: - Basic info (name, birth date, nationality, current location) - Immigration intent (target countries, types, timeline) - Education records with WES evaluation flag - Work records with NOC codes - Language scores (IELTS, TOEFL, etc.) - Family members info - Financial info for investment immigration - Profile completion percentage calculation - Add UserContact entity for identity binding: - Support multiple contact types (EMAIL, WECHAT, WHATSAPP, TELEGRAM, LINE) - Verification code flow with expiration - Notification settings (paid feature) - Notification types: POLICY_UPDATE, DEADLINE_REMINDER, etc. - Add API endpoints: - GET/PUT /users/me/profile/* for profile sections - GET/POST/PUT/DELETE /users/me/contacts for contact management - POST /users/me/contacts/:type/verification for verification flow - POST/PUT/DELETE /users/me/contacts/:type/notifications Co-Authored-By: Claude Opus 4.5 --- .../src/adapters/inbound/index.ts | 2 + .../inbound/user-contact.controller.ts | 185 ++++++++ .../inbound/user-profile.controller.ts | 154 +++++++ .../user-contact-postgres.repository.ts | 151 +++++++ .../user-profile-postgres.repository.ts | 171 ++++++++ .../src/application/dtos/user-contact.dto.ts | 53 +++ .../src/application/dtos/user-profile.dto.ts | 96 +++++ .../src/application/services/index.ts | 2 + .../services/user-contact.service.ts | 178 ++++++++ .../services/user-profile.service.ts | 149 +++++++ .../domain/entities/user-contact.entity.ts | 205 +++++++++ .../domain/entities/user-profile.entity.ts | 396 ++++++++++++++++++ .../user-contact.repository.interface.ts | 53 +++ .../user-profile.repository.interface.ts | 47 +++ .../postgres/entities/user-contact.orm.ts | 59 +++ .../postgres/entities/user-profile.orm.ts | 114 +++++ .../user-service/src/user/user.module.ts | 52 ++- .../migrations/20260125_add_user_profile.sql | 142 +++++++ 18 files changed, 2205 insertions(+), 4 deletions(-) create mode 100644 packages/services/user-service/src/adapters/inbound/user-contact.controller.ts create mode 100644 packages/services/user-service/src/adapters/inbound/user-profile.controller.ts create mode 100644 packages/services/user-service/src/adapters/outbound/persistence/user-contact-postgres.repository.ts create mode 100644 packages/services/user-service/src/adapters/outbound/persistence/user-profile-postgres.repository.ts create mode 100644 packages/services/user-service/src/application/dtos/user-contact.dto.ts create mode 100644 packages/services/user-service/src/application/dtos/user-profile.dto.ts create mode 100644 packages/services/user-service/src/application/services/user-contact.service.ts create mode 100644 packages/services/user-service/src/application/services/user-profile.service.ts create mode 100644 packages/services/user-service/src/domain/entities/user-contact.entity.ts create mode 100644 packages/services/user-service/src/domain/entities/user-profile.entity.ts create mode 100644 packages/services/user-service/src/domain/repositories/user-contact.repository.interface.ts create mode 100644 packages/services/user-service/src/domain/repositories/user-profile.repository.interface.ts create mode 100644 packages/services/user-service/src/infrastructure/database/postgres/entities/user-contact.orm.ts create mode 100644 packages/services/user-service/src/infrastructure/database/postgres/entities/user-profile.orm.ts create mode 100644 scripts/migrations/20260125_add_user_profile.sql diff --git a/packages/services/user-service/src/adapters/inbound/index.ts b/packages/services/user-service/src/adapters/inbound/index.ts index a36ac12..615ad29 100644 --- a/packages/services/user-service/src/adapters/inbound/index.ts +++ b/packages/services/user-service/src/adapters/inbound/index.ts @@ -1,3 +1,5 @@ export * from './user.controller'; export * from './auth.controller'; export * from './admin-user.controller'; +export * from './user-profile.controller'; +export * from './user-contact.controller'; diff --git a/packages/services/user-service/src/adapters/inbound/user-contact.controller.ts b/packages/services/user-service/src/adapters/inbound/user-contact.controller.ts new file mode 100644 index 0000000..7940e49 --- /dev/null +++ b/packages/services/user-service/src/adapters/inbound/user-contact.controller.ts @@ -0,0 +1,185 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Headers, +} from '@nestjs/common'; +import { UserContactService } from '../../application/services/user-contact.service'; +import { + AddContactDto, + UpdateContactValueDto, + VerifyContactDto, + EnableNotificationsDto, + UpdateNotificationTypesDto, + UserContactResponseDto, + VerificationResponseDto, +} from '../../application/dtos/user-contact.dto'; +import { UserContactEntity, ContactType } from '../../domain/entities/user-contact.entity'; + +@Controller('users/me/contacts') +export class UserContactController { + constructor(private readonly contactService: UserContactService) {} + + @Get() + async getContacts(@Headers('x-user-id') userId: string) { + const contacts = await this.contactService.getContacts(userId); + return { + success: true, + data: contacts.map(c => this.toResponseDto(c)), + }; + } + + @Get('verified') + async getVerifiedContacts(@Headers('x-user-id') userId: string) { + const contacts = await this.contactService.getVerifiedContacts(userId); + return { + success: true, + data: contacts.map(c => this.toResponseDto(c)), + }; + } + + @Post() + async addContact( + @Headers('x-user-id') userId: string, + @Body() dto: AddContactDto, + ) { + const contact = await this.contactService.addContact(userId, dto.type, dto.value); + return { + success: true, + data: this.toResponseDto(contact), + }; + } + + @Get(':type') + async getContact( + @Headers('x-user-id') userId: string, + @Param('type') type: ContactType, + ) { + const contact = await this.contactService.getContact(userId, type); + return { + success: true, + data: this.toResponseDto(contact), + }; + } + + @Put(':type') + async updateContactValue( + @Headers('x-user-id') userId: string, + @Param('type') type: ContactType, + @Body() dto: UpdateContactValueDto, + ) { + const contact = await this.contactService.updateContactValue(userId, type, dto.value); + return { + success: true, + data: this.toResponseDto(contact), + }; + } + + @Delete(':type') + async deleteContact( + @Headers('x-user-id') userId: string, + @Param('type') type: ContactType, + ) { + await this.contactService.deleteContact(userId, type); + return { + success: true, + message: 'Contact deleted', + }; + } + + @Post(':type/verification') + async requestVerification( + @Headers('x-user-id') userId: string, + @Param('type') type: ContactType, + ): Promise<{ success: boolean; data: VerificationResponseDto }> { + const result = await this.contactService.requestVerification(userId, type); + return { + success: true, + data: { + message: 'Verification code sent', + expiresAt: result.expiresAt, + // Include code in development mode only + code: process.env.NODE_ENV === 'development' ? result.code : undefined, + }, + }; + } + + @Put(':type/verification') + async verifyContact( + @Headers('x-user-id') userId: string, + @Param('type') type: ContactType, + @Body() dto: VerifyContactDto, + ) { + const contact = await this.contactService.verifyContact(userId, type, dto.code); + return { + success: true, + data: this.toResponseDto(contact), + }; + } + + @Post(':type/notifications') + async enableNotifications( + @Headers('x-user-id') userId: string, + @Param('type') type: ContactType, + @Body() dto: EnableNotificationsDto, + ) { + const contact = await this.contactService.enableNotifications( + userId, + type, + dto.notificationTypes, + ); + return { + success: true, + data: this.toResponseDto(contact), + }; + } + + @Delete(':type/notifications') + async disableNotifications( + @Headers('x-user-id') userId: string, + @Param('type') type: ContactType, + ) { + const contact = await this.contactService.disableNotifications(userId, type); + return { + success: true, + data: this.toResponseDto(contact), + }; + } + + @Put(':type/notifications') + async updateNotificationTypes( + @Headers('x-user-id') userId: string, + @Param('type') type: ContactType, + @Body() dto: UpdateNotificationTypesDto, + ) { + const contact = await this.contactService.updateNotificationTypes( + userId, + type, + dto.notificationTypes, + ); + return { + success: true, + data: this.toResponseDto(contact), + }; + } + + private toResponseDto(contact: UserContactEntity): UserContactResponseDto { + return { + id: contact.id, + userId: contact.userId, + type: contact.type, + value: contact.value, + displayName: contact.displayName, + isVerified: contact.isVerified, + verifiedAt: contact.verifiedAt, + notificationEnabled: contact.notificationEnabled, + enabledNotificationTypes: contact.enabledNotificationTypes, + createdAt: contact.createdAt, + updatedAt: contact.updatedAt, + }; + } +} diff --git a/packages/services/user-service/src/adapters/inbound/user-profile.controller.ts b/packages/services/user-service/src/adapters/inbound/user-profile.controller.ts new file mode 100644 index 0000000..cf007a4 --- /dev/null +++ b/packages/services/user-service/src/adapters/inbound/user-profile.controller.ts @@ -0,0 +1,154 @@ +import { + Controller, + Get, + Put, + Body, + Headers, + Query, +} from '@nestjs/common'; +import { UserProfileService } from '../../application/services/user-profile.service'; +import { + UpdateBasicInfoDto, + UpdateImmigrationIntentDto, + UpdateEducationDto, + UpdateWorkExperienceDto, + UpdateLanguageScoresDto, + UpdateFamilyMembersDto, + UpdateFinancialInfoDto, + UserProfileResponseDto, +} from '../../application/dtos/user-profile.dto'; +import { UserProfileEntity } from '../../domain/entities/user-profile.entity'; + +@Controller('users/me/profile') +export class UserProfileController { + constructor(private readonly profileService: UserProfileService) {} + + @Get() + async getProfile(@Headers('x-user-id') userId: string) { + const profile = await this.profileService.getOrCreateProfile(userId); + return { + success: true, + data: this.toResponseDto(profile), + }; + } + + @Put('basic-info') + async updateBasicInfo( + @Headers('x-user-id') userId: string, + @Body() dto: UpdateBasicInfoDto, + ) { + const data = { + ...dto, + birthDate: dto.birthDate ? new Date(dto.birthDate) : undefined, + }; + const profile = await this.profileService.updateBasicInfo(userId, data); + return { + success: true, + data: this.toResponseDto(profile), + }; + } + + @Put('immigration-intent') + async updateImmigrationIntent( + @Headers('x-user-id') userId: string, + @Body() dto: UpdateImmigrationIntentDto, + ) { + const profile = await this.profileService.updateImmigrationIntent(userId, dto); + return { + success: true, + data: this.toResponseDto(profile), + }; + } + + @Put('education') + async updateEducation( + @Headers('x-user-id') userId: string, + @Body() dto: UpdateEducationDto, + ) { + const profile = await this.profileService.updateEducation(userId, dto); + return { + success: true, + data: this.toResponseDto(profile), + }; + } + + @Put('work-experience') + async updateWorkExperience( + @Headers('x-user-id') userId: string, + @Body() dto: UpdateWorkExperienceDto, + ) { + const profile = await this.profileService.updateWorkExperience(userId, dto); + return { + success: true, + data: this.toResponseDto(profile), + }; + } + + @Put('language-scores') + async updateLanguageScores( + @Headers('x-user-id') userId: string, + @Body() dto: UpdateLanguageScoresDto, + ) { + const profile = await this.profileService.updateLanguageScores(userId, dto.scores); + return { + success: true, + data: this.toResponseDto(profile), + }; + } + + @Put('family-members') + async updateFamilyMembers( + @Headers('x-user-id') userId: string, + @Body() dto: UpdateFamilyMembersDto, + ) { + const profile = await this.profileService.updateFamilyMembers(userId, dto.members); + return { + success: true, + data: this.toResponseDto(profile), + }; + } + + @Put('financial-info') + async updateFinancialInfo( + @Headers('x-user-id') userId: string, + @Body() dto: UpdateFinancialInfoDto, + ) { + const profile = await this.profileService.updateFinancialInfo(userId, dto); + return { + success: true, + data: this.toResponseDto(profile), + }; + } + + private toResponseDto(profile: UserProfileEntity): UserProfileResponseDto { + return { + id: profile.id, + userId: profile.userId, + fullName: profile.fullName, + birthDate: profile.birthDate?.toISOString().split('T')[0] ?? null, + age: profile.getAge(), + nationality: profile.nationality, + currentCountry: profile.currentCountry, + currentCity: profile.currentCity, + maritalStatus: profile.maritalStatus, + targetCountries: profile.targetCountries, + immigrationTypes: profile.immigrationTypes, + plannedTimeline: profile.plannedTimeline, + primaryPurpose: profile.primaryPurpose, + highestEducation: profile.highestEducation, + educationRecords: profile.educationRecords, + currentOccupation: profile.currentOccupation, + totalWorkYears: profile.totalWorkYears, + workRecords: profile.workRecords, + languageScores: profile.languageScores, + familyMembers: profile.familyMembers, + netWorthRange: profile.netWorthRange, + hasBusinessExperience: profile.hasBusinessExperience, + businessYears: profile.businessYears, + hasOverseasExperience: profile.hasOverseasExperience, + completionPercentage: profile.completionPercentage, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + }; + } +} diff --git a/packages/services/user-service/src/adapters/outbound/persistence/user-contact-postgres.repository.ts b/packages/services/user-service/src/adapters/outbound/persistence/user-contact-postgres.repository.ts new file mode 100644 index 0000000..e8faaca --- /dev/null +++ b/packages/services/user-service/src/adapters/outbound/persistence/user-contact-postgres.repository.ts @@ -0,0 +1,151 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared'; +import { IUserContactRepository } from '../../../domain/repositories/user-contact.repository.interface'; +import { + UserContactEntity, + ContactType, + NotificationType, +} from '../../../domain/entities/user-contact.entity'; +import { UserContactORM } from '../../../infrastructure/database/postgres/entities/user-contact.orm'; + +@Injectable() +export class UserContactPostgresRepository + extends BaseTenantRepository + implements IUserContactRepository +{ + constructor( + @InjectRepository(UserContactORM) repo: Repository, + tenantContext: TenantContextService, + ) { + super(repo, tenantContext); + } + + async save(contact: UserContactEntity): Promise { + const orm = this.toORM(contact); + orm.tenantId = this.getTenantId(); + await this.repo.save(orm); + } + + async findById(id: string): Promise { + const orm = await this.findOneWithTenant({ id } as any); + return orm ? this.toEntity(orm) : null; + } + + async findByUserIdAndType(userId: string, type: ContactType): Promise { + const orm = await this.findOneWithTenant({ userId, type } as any); + return orm ? this.toEntity(orm) : null; + } + + async findByUserId(userId: string): Promise { + const orms = await this.repo.find({ + where: { userId, tenantId: this.getTenantId() } as any, + order: { createdAt: 'ASC' }, + }); + return orms.map(orm => this.toEntity(orm)); + } + + async findVerifiedByUserId(userId: string): Promise { + const orms = await this.repo.find({ + where: { userId, tenantId: this.getTenantId(), isVerified: true } as any, + order: { createdAt: 'ASC' }, + }); + return orms.map(orm => this.toEntity(orm)); + } + + async update(contact: UserContactEntity): Promise { + const orm = this.toORM(contact); + orm.tenantId = this.getTenantId(); + await this.repo.save(orm); + } + + async delete(id: string): Promise { + await this.repo.delete({ id, tenantId: this.getTenantId() } as any); + } + + async findByNotificationType( + notificationType: NotificationType, + options?: { limit?: number; offset?: number }, + ): Promise { + const query = this.createTenantQueryBuilder('contact') + .andWhere('contact.notification_enabled = true') + .andWhere('contact.is_verified = true') + .andWhere(':type = ANY(contact.enabled_notification_types)', { type: notificationType }) + .orderBy('contact.created_at', 'ASC'); + + if (options?.limit) { + query.take(options.limit); + } + if (options?.offset) { + query.skip(options.offset); + } + + const orms = await query.getMany(); + return orms.map(orm => this.toEntity(orm)); + } + + async countVerifiedByType(): Promise> { + const tenantId = this.getTenantId(); + + const result = await this.repo + .createQueryBuilder('contact') + .select('contact.type', 'type') + .addSelect('COUNT(*)', 'count') + .where('contact.tenant_id = :tenantId', { tenantId }) + .andWhere('contact.is_verified = true') + .groupBy('contact.type') + .getRawMany(); + + const counts: Record = { + [ContactType.EMAIL]: 0, + [ContactType.WECHAT]: 0, + [ContactType.WHATSAPP]: 0, + [ContactType.TELEGRAM]: 0, + [ContactType.LINE]: 0, + }; + + for (const row of result) { + counts[row.type as ContactType] = parseInt(row.count, 10); + } + + return counts; + } + + private toORM(entity: UserContactEntity): UserContactORM { + const orm = new UserContactORM(); + orm.id = entity.id; + orm.tenantId = this.getTenantId(); + orm.userId = entity.userId; + orm.type = entity.type; + orm.value = entity.value; + orm.displayName = entity.displayName; + orm.isVerified = entity.isVerified; + orm.verifiedAt = entity.verifiedAt; + orm.notificationEnabled = entity.notificationEnabled; + orm.enabledNotificationTypes = entity.enabledNotificationTypes; + orm.verificationCode = entity.verificationCode; + orm.verificationExpiresAt = entity.verificationExpiresAt; + orm.createdAt = entity.createdAt; + orm.updatedAt = entity.updatedAt; + return orm; + } + + private toEntity(orm: UserContactORM): UserContactEntity { + return UserContactEntity.fromPersistence({ + id: orm.id, + userId: orm.userId, + type: orm.type, + value: orm.value, + displayName: orm.displayName, + isVerified: orm.isVerified, + verifiedAt: orm.verifiedAt, + notificationEnabled: orm.notificationEnabled, + enabledNotificationTypes: orm.enabledNotificationTypes, + verificationCode: orm.verificationCode, + verificationExpiresAt: orm.verificationExpiresAt, + createdAt: orm.createdAt, + updatedAt: orm.updatedAt, + }); + } +} diff --git a/packages/services/user-service/src/adapters/outbound/persistence/user-profile-postgres.repository.ts b/packages/services/user-service/src/adapters/outbound/persistence/user-profile-postgres.repository.ts new file mode 100644 index 0000000..405616d --- /dev/null +++ b/packages/services/user-service/src/adapters/outbound/persistence/user-profile-postgres.repository.ts @@ -0,0 +1,171 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared'; +import { IUserProfileRepository } from '../../../domain/repositories/user-profile.repository.interface'; +import { + UserProfileEntity, + EducationRecord, + WorkRecord, + LanguageScore, + FamilyMember, + MaritalStatus, + ImmigrationType, + PlannedTimeline, + EducationLevel, + NetWorthRange, +} from '../../../domain/entities/user-profile.entity'; +import { UserProfileORM } from '../../../infrastructure/database/postgres/entities/user-profile.orm'; + +@Injectable() +export class UserProfilePostgresRepository + extends BaseTenantRepository + implements IUserProfileRepository +{ + constructor( + @InjectRepository(UserProfileORM) repo: Repository, + tenantContext: TenantContextService, + ) { + super(repo, tenantContext); + } + + async save(profile: UserProfileEntity): Promise { + const orm = this.toORM(profile); + orm.tenantId = this.getTenantId(); + await this.repo.save(orm); + } + + async findById(id: string): Promise { + const orm = await this.findOneWithTenant({ id } as any); + return orm ? this.toEntity(orm) : null; + } + + async findByUserId(userId: string): Promise { + const orm = await this.findOneWithTenant({ userId } as any); + return orm ? this.toEntity(orm) : null; + } + + async update(profile: UserProfileEntity): Promise { + const orm = this.toORM(profile); + orm.tenantId = this.getTenantId(); + await this.repo.save(orm); + } + + async delete(id: string): Promise { + await this.repo.delete({ id, tenantId: this.getTenantId() } as any); + } + + async findByTargetCountry( + countryCode: string, + options?: { limit?: number; offset?: number }, + ): Promise { + const query = this.createTenantQueryBuilder('profile') + .andWhere(':country = ANY(profile.target_countries)', { country: countryCode }) + .orderBy('profile.completion_percentage', 'DESC'); + + if (options?.limit) { + query.take(options.limit); + } + if (options?.offset) { + query.skip(options.offset); + } + + const orms = await query.getMany(); + return orms.map(orm => this.toEntity(orm)); + } + + async countByCompletionRange(): Promise<{ + low: number; + medium: number; + high: number; + }> { + const tenantId = this.getTenantId(); + + const result = await this.repo.query( + ` + SELECT + COUNT(CASE WHEN completion_percentage < 30 THEN 1 END) as low, + COUNT(CASE WHEN completion_percentage >= 30 AND completion_percentage < 70 THEN 1 END) as medium, + COUNT(CASE WHEN completion_percentage >= 70 THEN 1 END) as high + FROM user_profiles + WHERE tenant_id = $1 + `, + [tenantId], + ); + + return { + low: parseInt(result[0]?.low || '0', 10), + medium: parseInt(result[0]?.medium || '0', 10), + high: parseInt(result[0]?.high || '0', 10), + }; + } + + private toORM(entity: UserProfileEntity): UserProfileORM { + const orm = new UserProfileORM(); + orm.id = entity.id; + orm.tenantId = this.getTenantId(); + orm.userId = entity.userId; + orm.fullName = entity.fullName; + orm.birthDate = entity.birthDate; + orm.nationality = entity.nationality; + orm.currentCountry = entity.currentCountry; + orm.currentCity = entity.currentCity; + orm.maritalStatus = entity.maritalStatus; + orm.targetCountries = entity.targetCountries; + orm.immigrationTypes = entity.immigrationTypes; + orm.plannedTimeline = entity.plannedTimeline; + orm.primaryPurpose = entity.primaryPurpose; + orm.highestEducation = entity.highestEducation; + orm.educationRecords = entity.educationRecords as unknown as Record[]; + orm.currentOccupation = entity.currentOccupation; + orm.totalWorkYears = entity.totalWorkYears; + orm.workRecords = entity.workRecords as unknown as Record[]; + orm.languageScores = entity.languageScores as unknown as Record[]; + orm.familyMembers = entity.familyMembers as unknown as Record[]; + orm.netWorthRange = entity.netWorthRange; + orm.hasBusinessExperience = entity.hasBusinessExperience; + orm.businessYears = entity.businessYears; + orm.hasOverseasExperience = entity.hasOverseasExperience; + orm.hasCriminalRecord = entity.hasCriminalRecord; + orm.hasHealthIssues = entity.hasHealthIssues; + orm.additionalNotes = entity.additionalNotes; + orm.completionPercentage = entity.completionPercentage; + orm.createdAt = entity.createdAt; + orm.updatedAt = entity.updatedAt; + return orm; + } + + private toEntity(orm: UserProfileORM): UserProfileEntity { + return UserProfileEntity.fromPersistence({ + id: orm.id, + userId: orm.userId, + fullName: orm.fullName, + birthDate: orm.birthDate, + nationality: orm.nationality, + currentCountry: orm.currentCountry, + currentCity: orm.currentCity, + maritalStatus: orm.maritalStatus, + targetCountries: orm.targetCountries, + immigrationTypes: orm.immigrationTypes, + plannedTimeline: orm.plannedTimeline, + primaryPurpose: orm.primaryPurpose, + highestEducation: orm.highestEducation, + educationRecords: orm.educationRecords as unknown as EducationRecord[], + currentOccupation: orm.currentOccupation, + totalWorkYears: orm.totalWorkYears, + workRecords: orm.workRecords as unknown as WorkRecord[], + languageScores: orm.languageScores as unknown as LanguageScore[], + familyMembers: orm.familyMembers as unknown as FamilyMember[], + netWorthRange: orm.netWorthRange, + hasBusinessExperience: orm.hasBusinessExperience, + businessYears: orm.businessYears, + hasOverseasExperience: orm.hasOverseasExperience, + hasCriminalRecord: orm.hasCriminalRecord, + hasHealthIssues: orm.hasHealthIssues, + additionalNotes: orm.additionalNotes, + completionPercentage: orm.completionPercentage, + createdAt: orm.createdAt, + updatedAt: orm.updatedAt, + }); + } +} diff --git a/packages/services/user-service/src/application/dtos/user-contact.dto.ts b/packages/services/user-service/src/application/dtos/user-contact.dto.ts new file mode 100644 index 0000000..39e0276 --- /dev/null +++ b/packages/services/user-service/src/application/dtos/user-contact.dto.ts @@ -0,0 +1,53 @@ +import { ContactType, NotificationType } from '../../domain/entities/user-contact.entity'; + +// Request DTOs +export class AddContactDto { + type: ContactType; + value: string; +} + +export class UpdateContactValueDto { + value: string; +} + +export class VerifyContactDto { + code: string; +} + +export class EnableNotificationsDto { + notificationTypes: NotificationType[]; +} + +export class UpdateNotificationTypesDto { + notificationTypes: NotificationType[]; +} + +// Response DTOs +export class UserContactResponseDto { + id: string; + userId: string; + type: string; + value: string; + displayName: string | null; + isVerified: boolean; + verifiedAt: Date | null; + notificationEnabled: boolean; + enabledNotificationTypes: string[]; + createdAt: Date; + updatedAt: Date; +} + +export class VerificationResponseDto { + message: string; + expiresAt: Date; + // code is only included in development mode + code?: string; +} + +export class ContactStatsDto { + EMAIL: number; + WECHAT: number; + WHATSAPP: number; + TELEGRAM: number; + LINE: number; +} diff --git a/packages/services/user-service/src/application/dtos/user-profile.dto.ts b/packages/services/user-service/src/application/dtos/user-profile.dto.ts new file mode 100644 index 0000000..c8c1ffa --- /dev/null +++ b/packages/services/user-service/src/application/dtos/user-profile.dto.ts @@ -0,0 +1,96 @@ +import { + MaritalStatus, + ImmigrationType, + PlannedTimeline, + EducationLevel, + NetWorthRange, + EducationRecord, + WorkRecord, + LanguageScore, + FamilyMember, +} from '../../domain/entities/user-profile.entity'; + +// Basic info +export class UpdateBasicInfoDto { + fullName?: string; + birthDate?: string; // ISO date string + nationality?: string; + currentCountry?: string; + currentCity?: string; + maritalStatus?: MaritalStatus; +} + +// Immigration intent +export class UpdateImmigrationIntentDto { + targetCountries?: string[]; + immigrationTypes?: ImmigrationType[]; + plannedTimeline?: PlannedTimeline; + primaryPurpose?: string; +} + +// Education +export class UpdateEducationDto { + highestEducation?: EducationLevel; + educationRecords?: EducationRecord[]; +} + +// Work experience +export class UpdateWorkExperienceDto { + currentOccupation?: string; + totalWorkYears?: number; + workRecords?: WorkRecord[]; +} + +// Language scores +export class UpdateLanguageScoresDto { + scores: LanguageScore[]; +} + +// Family members +export class UpdateFamilyMembersDto { + members: FamilyMember[]; +} + +// Financial info +export class UpdateFinancialInfoDto { + netWorthRange?: NetWorthRange; + hasBusinessExperience?: boolean; + businessYears?: number; +} + +// Response DTOs +export class UserProfileResponseDto { + id: string; + userId: string; + fullName: string | null; + birthDate: string | null; + age: number | null; + nationality: string | null; + currentCountry: string | null; + currentCity: string | null; + maritalStatus: string | null; + targetCountries: string[]; + immigrationTypes: string[]; + plannedTimeline: string | null; + primaryPurpose: string | null; + highestEducation: string | null; + educationRecords: EducationRecord[]; + currentOccupation: string | null; + totalWorkYears: number | null; + workRecords: WorkRecord[]; + languageScores: LanguageScore[]; + familyMembers: FamilyMember[]; + netWorthRange: string | null; + hasBusinessExperience: boolean; + businessYears: number | null; + hasOverseasExperience: boolean; + completionPercentage: number; + createdAt: Date; + updatedAt: Date; +} + +export class ProfileCompletionStatsDto { + low: number; + medium: number; + high: number; +} diff --git a/packages/services/user-service/src/application/services/index.ts b/packages/services/user-service/src/application/services/index.ts index d99a6a5..179d35a 100644 --- a/packages/services/user-service/src/application/services/index.ts +++ b/packages/services/user-service/src/application/services/index.ts @@ -1,2 +1,4 @@ export * from './user.service'; export * from './auth.service'; +export * from './user-profile.service'; +export * from './user-contact.service'; diff --git a/packages/services/user-service/src/application/services/user-contact.service.ts b/packages/services/user-service/src/application/services/user-contact.service.ts new file mode 100644 index 0000000..ba18bae --- /dev/null +++ b/packages/services/user-service/src/application/services/user-contact.service.ts @@ -0,0 +1,178 @@ +import { + Injectable, + Inject, + NotFoundException, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; +import { + IUserContactRepository, + USER_CONTACT_REPOSITORY, +} from '../../domain/repositories/user-contact.repository.interface'; +import { + UserContactEntity, + ContactType, + NotificationType, +} from '../../domain/entities/user-contact.entity'; + +@Injectable() +export class UserContactService { + constructor( + @Inject(USER_CONTACT_REPOSITORY) + private readonly contactRepo: IUserContactRepository, + ) {} + + async addContact( + userId: string, + type: ContactType, + value: string, + ): Promise { + // Check if contact type already exists for user + const existing = await this.contactRepo.findByUserIdAndType(userId, type); + if (existing) { + throw new BadRequestException(`Contact type ${type} already exists`); + } + + const contact = UserContactEntity.create(userId, type, value); + await this.contactRepo.save(contact); + return contact; + } + + async getContacts(userId: string): Promise { + return this.contactRepo.findByUserId(userId); + } + + async getVerifiedContacts(userId: string): Promise { + return this.contactRepo.findVerifiedByUserId(userId); + } + + async getContact(userId: string, type: ContactType): Promise { + const contact = await this.contactRepo.findByUserIdAndType(userId, type); + if (!contact) { + throw new NotFoundException(`Contact type ${type} not found`); + } + return contact; + } + + async updateContactValue( + userId: string, + type: ContactType, + value: string, + ): Promise { + const contact = await this.getContact(userId, type); + contact.updateValue(value); + await this.contactRepo.update(contact); + return contact; + } + + async deleteContact(userId: string, type: ContactType): Promise { + const contact = await this.getContact(userId, type); + await this.contactRepo.delete(contact.id); + } + + async requestVerification( + userId: string, + type: ContactType, + ): Promise<{ code: string; expiresAt: Date }> { + const contact = await this.getContact(userId, type); + + if (contact.isVerified) { + throw new BadRequestException('Contact is already verified'); + } + + const code = contact.generateVerificationCode(); + await this.contactRepo.update(contact); + + // In production, send the code via email/SMS/etc. + // For now, return it directly (development mode) + return { + code, + expiresAt: contact.verificationExpiresAt!, + }; + } + + async verifyContact( + userId: string, + type: ContactType, + code: string, + ): Promise { + const contact = await this.getContact(userId, type); + + if (contact.isVerified) { + throw new BadRequestException('Contact is already verified'); + } + + const success = contact.verify(code); + if (!success) { + throw new BadRequestException('Invalid or expired verification code'); + } + + await this.contactRepo.update(contact); + return contact; + } + + async markAsVerified( + userId: string, + type: ContactType, + displayName?: string, + ): Promise { + const contact = await this.getContact(userId, type); + contact.markAsVerified(displayName); + await this.contactRepo.update(contact); + return contact; + } + + async enableNotifications( + userId: string, + type: ContactType, + notificationTypes: NotificationType[], + ): Promise { + const contact = await this.getContact(userId, type); + + if (!contact.isVerified) { + throw new ForbiddenException('Contact must be verified before enabling notifications'); + } + + contact.enableNotifications(notificationTypes); + await this.contactRepo.update(contact); + return contact; + } + + async disableNotifications( + userId: string, + type: ContactType, + ): Promise { + const contact = await this.getContact(userId, type); + contact.disableNotifications(); + await this.contactRepo.update(contact); + return contact; + } + + async updateNotificationTypes( + userId: string, + type: ContactType, + notificationTypes: NotificationType[], + ): Promise { + const contact = await this.getContact(userId, type); + + if (!contact.notificationEnabled) { + throw new BadRequestException('Notifications are not enabled for this contact'); + } + + contact.updateNotificationTypes(notificationTypes); + await this.contactRepo.update(contact); + return contact; + } + + // Admin methods + async findByNotificationType( + notificationType: NotificationType, + options?: { limit?: number; offset?: number }, + ): Promise { + return this.contactRepo.findByNotificationType(notificationType, options); + } + + async getVerifiedContactStats(): Promise> { + return this.contactRepo.countVerifiedByType(); + } +} diff --git a/packages/services/user-service/src/application/services/user-profile.service.ts b/packages/services/user-service/src/application/services/user-profile.service.ts new file mode 100644 index 0000000..5f55594 --- /dev/null +++ b/packages/services/user-service/src/application/services/user-profile.service.ts @@ -0,0 +1,149 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { + IUserProfileRepository, + USER_PROFILE_REPOSITORY, +} from '../../domain/repositories/user-profile.repository.interface'; +import { + UserProfileEntity, + EducationLevel, + ImmigrationType, + PlannedTimeline, + MaritalStatus, + NetWorthRange, + EducationRecord, + WorkRecord, + LanguageScore, + FamilyMember, +} from '../../domain/entities/user-profile.entity'; + +@Injectable() +export class UserProfileService { + constructor( + @Inject(USER_PROFILE_REPOSITORY) + private readonly profileRepo: IUserProfileRepository, + ) {} + + async getOrCreateProfile(userId: string): Promise { + const existing = await this.profileRepo.findByUserId(userId); + if (existing) { + return existing; + } + + const profile = UserProfileEntity.create(userId); + await this.profileRepo.save(profile); + return profile; + } + + async getProfile(userId: string): Promise { + const profile = await this.profileRepo.findByUserId(userId); + if (!profile) { + throw new NotFoundException('Profile not found'); + } + return profile; + } + + async updateBasicInfo( + userId: string, + data: { + fullName?: string; + birthDate?: Date; + nationality?: string; + currentCountry?: string; + currentCity?: string; + maritalStatus?: MaritalStatus; + }, + ): Promise { + const profile = await this.getOrCreateProfile(userId); + profile.updateBasicInfo(data); + await this.profileRepo.update(profile); + return profile; + } + + async updateImmigrationIntent( + userId: string, + data: { + targetCountries?: string[]; + immigrationTypes?: ImmigrationType[]; + plannedTimeline?: PlannedTimeline; + primaryPurpose?: string; + }, + ): Promise { + const profile = await this.getOrCreateProfile(userId); + profile.updateImmigrationIntent(data); + await this.profileRepo.update(profile); + return profile; + } + + async updateEducation( + userId: string, + data: { + highestEducation?: EducationLevel; + educationRecords?: EducationRecord[]; + }, + ): Promise { + const profile = await this.getOrCreateProfile(userId); + profile.updateEducation(data); + await this.profileRepo.update(profile); + return profile; + } + + async updateWorkExperience( + userId: string, + data: { + currentOccupation?: string; + totalWorkYears?: number; + workRecords?: WorkRecord[]; + }, + ): Promise { + const profile = await this.getOrCreateProfile(userId); + profile.updateWorkExperience(data); + await this.profileRepo.update(profile); + return profile; + } + + async updateLanguageScores( + userId: string, + scores: LanguageScore[], + ): Promise { + const profile = await this.getOrCreateProfile(userId); + profile.updateLanguageScores(scores); + await this.profileRepo.update(profile); + return profile; + } + + async updateFamilyMembers( + userId: string, + members: FamilyMember[], + ): Promise { + const profile = await this.getOrCreateProfile(userId); + profile.updateFamilyMembers(members); + await this.profileRepo.update(profile); + return profile; + } + + async updateFinancialInfo( + userId: string, + data: { + netWorthRange?: NetWorthRange; + hasBusinessExperience?: boolean; + businessYears?: number; + }, + ): Promise { + const profile = await this.getOrCreateProfile(userId); + profile.updateFinancialInfo(data); + await this.profileRepo.update(profile); + return profile; + } + + // Admin methods + async findByTargetCountry( + countryCode: string, + options?: { limit?: number; offset?: number }, + ): Promise { + return this.profileRepo.findByTargetCountry(countryCode, options); + } + + async getCompletionStats(): Promise<{ low: number; medium: number; high: number }> { + return this.profileRepo.countByCompletionRange(); + } +} diff --git a/packages/services/user-service/src/domain/entities/user-contact.entity.ts b/packages/services/user-service/src/domain/entities/user-contact.entity.ts new file mode 100644 index 0000000..fb5583f --- /dev/null +++ b/packages/services/user-service/src/domain/entities/user-contact.entity.ts @@ -0,0 +1,205 @@ +/** + * 联系方式类型 + */ +export enum ContactType { + EMAIL = 'EMAIL', + WECHAT = 'WECHAT', + WHATSAPP = 'WHATSAPP', + TELEGRAM = 'TELEGRAM', + LINE = 'LINE', +} + +/** + * 通知类型 + */ +export enum NotificationType { + POLICY_UPDATE = 'POLICY_UPDATE', // 政策变动 + DEADLINE_REMINDER = 'DEADLINE_REMINDER', // 截止日期提醒 + APPOINTMENT = 'APPOINTMENT', // 预约提醒 + CASE_UPDATE = 'CASE_UPDATE', // 案件进度 + PROMOTION = 'PROMOTION', // 优惠活动 +} + +/** + * User Contact Domain Entity + * 用户联系方式 (用于通知推送) + */ +export class UserContactEntity { + readonly id: string; + readonly userId: string; + type: ContactType; + value: string; // 邮箱地址/微信openid/手机号等 + displayName: string | null; // 显示名称 (如微信昵称) + isVerified: boolean; + verifiedAt: Date | null; + + // 通知设置 (付费功能) + notificationEnabled: boolean; + enabledNotificationTypes: NotificationType[]; + + // 验证相关 + verificationCode: string | null; + verificationExpiresAt: Date | null; + + readonly createdAt: Date; + updatedAt: Date; + + private constructor(props: { + id: string; + userId: string; + type: ContactType; + value: string; + displayName?: string | null; + isVerified?: boolean; + verifiedAt?: Date | null; + notificationEnabled?: boolean; + enabledNotificationTypes?: NotificationType[]; + verificationCode?: string | null; + verificationExpiresAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; + }) { + this.id = props.id; + this.userId = props.userId; + this.type = props.type; + this.value = props.value; + this.displayName = props.displayName ?? null; + this.isVerified = props.isVerified ?? false; + this.verifiedAt = props.verifiedAt ?? null; + this.notificationEnabled = props.notificationEnabled ?? false; + this.enabledNotificationTypes = props.enabledNotificationTypes ?? []; + this.verificationCode = props.verificationCode ?? null; + this.verificationExpiresAt = props.verificationExpiresAt ?? null; + this.createdAt = props.createdAt ?? new Date(); + this.updatedAt = props.updatedAt ?? new Date(); + } + + /** + * Create a new contact + */ + static create(userId: string, type: ContactType, value: string): UserContactEntity { + return new UserContactEntity({ + id: crypto.randomUUID(), + userId, + type, + value, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + /** + * Reconstruct from persistence + */ + static fromPersistence(props: { + id: string; + userId: string; + type: string; + value: string; + displayName: string | null; + isVerified: boolean; + verifiedAt: Date | null; + notificationEnabled: boolean; + enabledNotificationTypes: string[]; + verificationCode: string | null; + verificationExpiresAt: Date | null; + createdAt: Date; + updatedAt: Date; + }): UserContactEntity { + return new UserContactEntity({ + ...props, + type: props.type as ContactType, + enabledNotificationTypes: props.enabledNotificationTypes as NotificationType[], + }); + } + + /** + * Generate verification code + */ + generateVerificationCode(): string { + const code = Math.random().toString(36).substring(2, 8).toUpperCase(); + this.verificationCode = code; + this.verificationExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + this.updatedAt = new Date(); + return code; + } + + /** + * Verify with code + */ + verify(code: string): boolean { + if (!this.verificationCode || !this.verificationExpiresAt) { + return false; + } + + if (new Date() > this.verificationExpiresAt) { + return false; + } + + if (this.verificationCode !== code) { + return false; + } + + this.isVerified = true; + this.verifiedAt = new Date(); + this.verificationCode = null; + this.verificationExpiresAt = null; + this.updatedAt = new Date(); + return true; + } + + /** + * Mark as verified (for OAuth bindings) + */ + markAsVerified(displayName?: string): void { + this.isVerified = true; + this.verifiedAt = new Date(); + if (displayName) { + this.displayName = displayName; + } + this.updatedAt = new Date(); + } + + /** + * Enable notifications + */ + enableNotifications(types: NotificationType[]): void { + this.notificationEnabled = true; + this.enabledNotificationTypes = types; + this.updatedAt = new Date(); + } + + /** + * Disable notifications + */ + disableNotifications(): void { + this.notificationEnabled = false; + this.enabledNotificationTypes = []; + this.updatedAt = new Date(); + } + + /** + * Update notification types + */ + updateNotificationTypes(types: NotificationType[]): void { + this.enabledNotificationTypes = types; + this.updatedAt = new Date(); + } + + /** + * Check if notification type is enabled + */ + isNotificationTypeEnabled(type: NotificationType): boolean { + return this.notificationEnabled && this.enabledNotificationTypes.includes(type); + } + + /** + * Update contact value + */ + updateValue(value: string): void { + this.value = value; + this.isVerified = false; + this.verifiedAt = null; + this.updatedAt = new Date(); + } +} diff --git a/packages/services/user-service/src/domain/entities/user-profile.entity.ts b/packages/services/user-service/src/domain/entities/user-profile.entity.ts new file mode 100644 index 0000000..0c18dda --- /dev/null +++ b/packages/services/user-service/src/domain/entities/user-profile.entity.ts @@ -0,0 +1,396 @@ +/** + * 婚姻状态 + */ +export enum MaritalStatus { + SINGLE = 'SINGLE', + MARRIED = 'MARRIED', + DIVORCED = 'DIVORCED', + WIDOWED = 'WIDOWED', +} + +/** + * 移民类型 + */ +export enum ImmigrationType { + SKILLED = 'SKILLED', // 技术移民 + INVESTMENT = 'INVESTMENT', // 投资移民 + BUSINESS = 'BUSINESS', // 企业家移民 + STUDY = 'STUDY', // 留学移民 + FAMILY = 'FAMILY', // 家庭团聚 + REFUGEE = 'REFUGEE', // 难民/庇护 + WORK_PERMIT = 'WORK_PERMIT', // 工作签证 +} + +/** + * 计划时间线 + */ +export enum PlannedTimeline { + WITHIN_6M = 'WITHIN_6M', // 6个月内 + WITHIN_1Y = 'WITHIN_1Y', // 1年内 + ONE_TO_TWO_Y = '1_2Y', // 1-2年 + TWO_TO_THREE_Y = '2_3Y', // 2-3年 + NO_PLAN = 'NO_PLAN', // 暂无明确计划 +} + +/** + * 净资产范围 + */ +export enum NetWorthRange { + UNDER_500K = 'UNDER_500K', // <50万 + FROM_500K_1M = '500K_1M', // 50-100万 + FROM_1M_5M = '1M_5M', // 100-500万 + FROM_5M_10M = '5M_10M', // 500-1000万 + OVER_10M = 'OVER_10M', // >1000万 +} + +/** + * 学历等级 + */ +export enum EducationLevel { + HIGH_SCHOOL = 'HIGH_SCHOOL', + ASSOCIATE = 'ASSOCIATE', // 大专 + BACHELOR = 'BACHELOR', // 本科 + MASTER = 'MASTER', // 硕士 + DOCTORATE = 'DOCTORATE', // 博士 + OTHER = 'OTHER', +} + +/** + * 教育记录 + */ +export interface EducationRecord { + level: EducationLevel; + major: string; + institution: string; + country: string; + graduationYear: number; + isWesEvaluated?: boolean; // 是否WES认证 +} + +/** + * 工作记录 + */ +export interface WorkRecord { + jobTitle: string; + nocCode?: string; // 加拿大NOC代码 + employer: string; + country: string; + startDate: string; // YYYY-MM + endDate?: string; // YYYY-MM or null for current + isCurrent: boolean; + isManagement: boolean; +} + +/** + * 语言成绩 + */ +export interface LanguageScore { + language: 'ENGLISH' | 'FRENCH' | 'OTHER'; + testType: string; // IELTS, TOEFL, TEF, TCF, etc. + overallScore: number; + listening?: number; + reading?: number; + writing?: number; + speaking?: number; + testDate: string; // YYYY-MM-DD + expiryDate?: string; // YYYY-MM-DD +} + +/** + * 家庭成员 + */ +export interface FamilyMember { + relationship: 'SPOUSE' | 'CHILD' | 'PARENT' | 'SIBLING'; + birthYear: number; + nationality: string; + willAccompany: boolean; // 是否随行 + educationLevel?: EducationLevel; + hasWorkExperience?: boolean; +} + +/** + * User Profile Domain Entity + * 用户移民档案 + */ +export class UserProfileEntity { + readonly id: string; + readonly userId: string; + + // 基本信息 + fullName: string | null; + birthDate: Date | null; + nationality: string | null; + currentCountry: string | null; + currentCity: string | null; + maritalStatus: MaritalStatus | null; + + // 移民意向 + targetCountries: string[]; // ISO 3166-1 alpha-2 codes + immigrationTypes: ImmigrationType[]; + plannedTimeline: PlannedTimeline | null; + primaryPurpose: string | null; // 移民主要目的 + + // 教育背景 + highestEducation: EducationLevel | null; + educationRecords: EducationRecord[]; + + // 工作经历 + currentOccupation: string | null; + totalWorkYears: number | null; + workRecords: WorkRecord[]; + + // 语言能力 + languageScores: LanguageScore[]; + + // 家庭信息 + familyMembers: FamilyMember[]; + + // 资产信息 (投资移民用) + netWorthRange: NetWorthRange | null; + hasBusinessExperience: boolean; + businessYears: number | null; + + // 其他 + hasOverseasExperience: boolean; // 海外经历 + hasCriminalRecord: boolean | null; // 犯罪记录 + hasHealthIssues: boolean | null; // 健康问题 + additionalNotes: string | null; // 补充说明 + + // 完成度 + completionPercentage: number; + + readonly createdAt: Date; + updatedAt: Date; + + private constructor(props: Partial & { id: string; userId: string }) { + this.id = props.id; + this.userId = props.userId; + + this.fullName = props.fullName ?? null; + this.birthDate = props.birthDate ?? null; + this.nationality = props.nationality ?? null; + this.currentCountry = props.currentCountry ?? null; + this.currentCity = props.currentCity ?? null; + this.maritalStatus = props.maritalStatus ?? null; + + this.targetCountries = props.targetCountries ?? []; + this.immigrationTypes = props.immigrationTypes ?? []; + this.plannedTimeline = props.plannedTimeline ?? null; + this.primaryPurpose = props.primaryPurpose ?? null; + + this.highestEducation = props.highestEducation ?? null; + this.educationRecords = props.educationRecords ?? []; + + this.currentOccupation = props.currentOccupation ?? null; + this.totalWorkYears = props.totalWorkYears ?? null; + this.workRecords = props.workRecords ?? []; + + this.languageScores = props.languageScores ?? []; + + this.familyMembers = props.familyMembers ?? []; + + this.netWorthRange = props.netWorthRange ?? null; + this.hasBusinessExperience = props.hasBusinessExperience ?? false; + this.businessYears = props.businessYears ?? null; + + this.hasOverseasExperience = props.hasOverseasExperience ?? false; + this.hasCriminalRecord = props.hasCriminalRecord ?? null; + this.hasHealthIssues = props.hasHealthIssues ?? null; + this.additionalNotes = props.additionalNotes ?? null; + + this.completionPercentage = props.completionPercentage ?? 0; + + this.createdAt = props.createdAt ?? new Date(); + this.updatedAt = props.updatedAt ?? new Date(); + } + + /** + * Create a new empty profile for user + */ + static create(userId: string): UserProfileEntity { + return new UserProfileEntity({ + id: crypto.randomUUID(), + userId, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + /** + * Reconstruct from persistence + */ + static fromPersistence(props: { + id: string; + userId: string; + fullName: string | null; + birthDate: Date | null; + nationality: string | null; + currentCountry: string | null; + currentCity: string | null; + maritalStatus: string | null; + targetCountries: string[]; + immigrationTypes: string[]; + plannedTimeline: string | null; + primaryPurpose: string | null; + highestEducation: string | null; + educationRecords: EducationRecord[]; + currentOccupation: string | null; + totalWorkYears: number | null; + workRecords: WorkRecord[]; + languageScores: LanguageScore[]; + familyMembers: FamilyMember[]; + netWorthRange: string | null; + hasBusinessExperience: boolean; + businessYears: number | null; + hasOverseasExperience: boolean; + hasCriminalRecord: boolean | null; + hasHealthIssues: boolean | null; + additionalNotes: string | null; + completionPercentage: number; + createdAt: Date; + updatedAt: Date; + }): UserProfileEntity { + return new UserProfileEntity({ + ...props, + maritalStatus: props.maritalStatus as MaritalStatus | null, + immigrationTypes: props.immigrationTypes as ImmigrationType[], + plannedTimeline: props.plannedTimeline as PlannedTimeline | null, + highestEducation: props.highestEducation as EducationLevel | null, + netWorthRange: props.netWorthRange as NetWorthRange | null, + }); + } + + /** + * Update basic info + */ + updateBasicInfo(data: { + fullName?: string; + birthDate?: Date; + nationality?: string; + currentCountry?: string; + currentCity?: string; + maritalStatus?: MaritalStatus; + }): void { + if (data.fullName !== undefined) this.fullName = data.fullName; + if (data.birthDate !== undefined) this.birthDate = data.birthDate; + if (data.nationality !== undefined) this.nationality = data.nationality; + if (data.currentCountry !== undefined) this.currentCountry = data.currentCountry; + if (data.currentCity !== undefined) this.currentCity = data.currentCity; + if (data.maritalStatus !== undefined) this.maritalStatus = data.maritalStatus; + this.recalculateCompletion(); + this.updatedAt = new Date(); + } + + /** + * Update immigration intent + */ + updateImmigrationIntent(data: { + targetCountries?: string[]; + immigrationTypes?: ImmigrationType[]; + plannedTimeline?: PlannedTimeline; + primaryPurpose?: string; + }): void { + if (data.targetCountries !== undefined) this.targetCountries = data.targetCountries; + if (data.immigrationTypes !== undefined) this.immigrationTypes = data.immigrationTypes; + if (data.plannedTimeline !== undefined) this.plannedTimeline = data.plannedTimeline; + if (data.primaryPurpose !== undefined) this.primaryPurpose = data.primaryPurpose; + this.recalculateCompletion(); + this.updatedAt = new Date(); + } + + /** + * Update education + */ + updateEducation(data: { + highestEducation?: EducationLevel; + educationRecords?: EducationRecord[]; + }): void { + if (data.highestEducation !== undefined) this.highestEducation = data.highestEducation; + if (data.educationRecords !== undefined) this.educationRecords = data.educationRecords; + this.recalculateCompletion(); + this.updatedAt = new Date(); + } + + /** + * Update work experience + */ + updateWorkExperience(data: { + currentOccupation?: string; + totalWorkYears?: number; + workRecords?: WorkRecord[]; + }): void { + if (data.currentOccupation !== undefined) this.currentOccupation = data.currentOccupation; + if (data.totalWorkYears !== undefined) this.totalWorkYears = data.totalWorkYears; + if (data.workRecords !== undefined) this.workRecords = data.workRecords; + this.recalculateCompletion(); + this.updatedAt = new Date(); + } + + /** + * Update language scores + */ + updateLanguageScores(scores: LanguageScore[]): void { + this.languageScores = scores; + this.recalculateCompletion(); + this.updatedAt = new Date(); + } + + /** + * Update family members + */ + updateFamilyMembers(members: FamilyMember[]): void { + this.familyMembers = members; + this.recalculateCompletion(); + this.updatedAt = new Date(); + } + + /** + * Update financial info + */ + updateFinancialInfo(data: { + netWorthRange?: NetWorthRange; + hasBusinessExperience?: boolean; + businessYears?: number; + }): void { + if (data.netWorthRange !== undefined) this.netWorthRange = data.netWorthRange; + if (data.hasBusinessExperience !== undefined) this.hasBusinessExperience = data.hasBusinessExperience; + if (data.businessYears !== undefined) this.businessYears = data.businessYears; + this.recalculateCompletion(); + this.updatedAt = new Date(); + } + + /** + * Calculate profile completion percentage + */ + private recalculateCompletion(): void { + let filled = 0; + const total = 10; + + if (this.fullName && this.birthDate && this.nationality) filled++; + if (this.currentCountry) filled++; + if (this.targetCountries.length > 0) filled++; + if (this.immigrationTypes.length > 0) filled++; + if (this.highestEducation) filled++; + if (this.educationRecords.length > 0) filled++; + if (this.currentOccupation || this.workRecords.length > 0) filled++; + if (this.languageScores.length > 0) filled++; + if (this.maritalStatus) filled++; + if (this.plannedTimeline) filled++; + + this.completionPercentage = Math.round((filled / total) * 100); + } + + /** + * Get age from birth date + */ + getAge(): number | null { + if (!this.birthDate) return null; + const today = new Date(); + let age = today.getFullYear() - this.birthDate.getFullYear(); + const monthDiff = today.getMonth() - this.birthDate.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < this.birthDate.getDate())) { + age--; + } + return age; + } +} diff --git a/packages/services/user-service/src/domain/repositories/user-contact.repository.interface.ts b/packages/services/user-service/src/domain/repositories/user-contact.repository.interface.ts new file mode 100644 index 0000000..f7408ae --- /dev/null +++ b/packages/services/user-service/src/domain/repositories/user-contact.repository.interface.ts @@ -0,0 +1,53 @@ +import { UserContactEntity, ContactType, NotificationType } from '../entities/user-contact.entity'; + +export interface IUserContactRepository { + /** + * 保存联系方式 + */ + save(contact: UserContactEntity): Promise; + + /** + * 根据ID查找 + */ + findById(id: string): Promise; + + /** + * 根据用户ID和类型查找 + */ + findByUserIdAndType(userId: string, type: ContactType): Promise; + + /** + * 获取用户所有联系方式 + */ + findByUserId(userId: string): Promise; + + /** + * 获取用户已验证的联系方式 + */ + findVerifiedByUserId(userId: string): Promise; + + /** + * 更新联系方式 + */ + update(contact: UserContactEntity): Promise; + + /** + * 删除联系方式 + */ + delete(id: string): Promise; + + /** + * 查找启用指定通知类型的联系方式 + */ + findByNotificationType( + notificationType: NotificationType, + options?: { limit?: number; offset?: number }, + ): Promise; + + /** + * 统计已验证的联系方式数量(按类型) + */ + countVerifiedByType(): Promise>; +} + +export const USER_CONTACT_REPOSITORY = Symbol('USER_CONTACT_REPOSITORY'); diff --git a/packages/services/user-service/src/domain/repositories/user-profile.repository.interface.ts b/packages/services/user-service/src/domain/repositories/user-profile.repository.interface.ts new file mode 100644 index 0000000..be85791 --- /dev/null +++ b/packages/services/user-service/src/domain/repositories/user-profile.repository.interface.ts @@ -0,0 +1,47 @@ +import { UserProfileEntity } from '../entities/user-profile.entity'; + +export interface IUserProfileRepository { + /** + * 保存用户档案 + */ + save(profile: UserProfileEntity): Promise; + + /** + * 根据ID查找档案 + */ + findById(id: string): Promise; + + /** + * 根据用户ID查找档案 + */ + findByUserId(userId: string): Promise; + + /** + * 更新档案 + */ + update(profile: UserProfileEntity): Promise; + + /** + * 删除档案 + */ + delete(id: string): Promise; + + /** + * 根据目标国家查找用户档案 + */ + findByTargetCountry(countryCode: string, options?: { + limit?: number; + offset?: number; + }): Promise; + + /** + * 统计档案完成度分布 + */ + countByCompletionRange(): Promise<{ + low: number; // 0-30% + medium: number; // 30-70% + high: number; // 70-100% + }>; +} + +export const USER_PROFILE_REPOSITORY = Symbol('USER_PROFILE_REPOSITORY'); diff --git a/packages/services/user-service/src/infrastructure/database/postgres/entities/user-contact.orm.ts b/packages/services/user-service/src/infrastructure/database/postgres/entities/user-contact.orm.ts new file mode 100644 index 0000000..1198c49 --- /dev/null +++ b/packages/services/user-service/src/infrastructure/database/postgres/entities/user-contact.orm.ts @@ -0,0 +1,59 @@ +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('user_contacts') +@Index('idx_user_contacts_tenant', ['tenantId']) +@Index('idx_user_contacts_user', ['tenantId', 'userId']) +@Index('idx_user_contacts_type', ['tenantId', 'userId', 'type']) +@Index('idx_user_contacts_verified', ['tenantId', 'isVerified']) +export class UserContactORM { + @PrimaryColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ type: 'varchar', length: 20 }) + type: string; + + @Column({ type: 'varchar', length: 255 }) + value: string; + + @Column({ name: 'display_name', type: 'varchar', length: 100, nullable: true }) + displayName: string | null; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date | null; + + // 通知设置 + @Column({ name: 'notification_enabled', type: 'boolean', default: false }) + notificationEnabled: boolean; + + @Column({ name: 'enabled_notification_types', type: 'text', array: true, default: '{}' }) + enabledNotificationTypes: string[]; + + // 验证相关 + @Column({ name: 'verification_code', type: 'varchar', length: 10, nullable: true }) + verificationCode: string | null; + + @Column({ name: 'verification_expires_at', type: 'timestamptz', nullable: true }) + verificationExpiresAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/packages/services/user-service/src/infrastructure/database/postgres/entities/user-profile.orm.ts b/packages/services/user-service/src/infrastructure/database/postgres/entities/user-profile.orm.ts new file mode 100644 index 0000000..1c5c170 --- /dev/null +++ b/packages/services/user-service/src/infrastructure/database/postgres/entities/user-profile.orm.ts @@ -0,0 +1,114 @@ +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('user_profiles') +@Index('idx_user_profiles_tenant', ['tenantId']) +@Index('idx_user_profiles_user', ['userId']) +@Index('idx_user_profiles_nationality', ['tenantId', 'nationality']) +@Index('idx_user_profiles_target_countries', ['tenantId'], { where: "target_countries != '{}'" }) +export class UserProfileORM { + @PrimaryColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid', unique: true }) + userId: string; + + // 基本信息 + @Column({ name: 'full_name', type: 'varchar', length: 100, nullable: true }) + fullName: string | null; + + @Column({ name: 'birth_date', type: 'date', nullable: true }) + birthDate: Date | null; + + @Column({ type: 'varchar', length: 2, nullable: true }) + nationality: string | null; + + @Column({ name: 'current_country', type: 'varchar', length: 2, nullable: true }) + currentCountry: string | null; + + @Column({ name: 'current_city', type: 'varchar', length: 100, nullable: true }) + currentCity: string | null; + + @Column({ name: 'marital_status', type: 'varchar', length: 20, nullable: true }) + maritalStatus: string | null; + + // 移民意向 + @Column({ name: 'target_countries', type: 'text', array: true, default: '{}' }) + targetCountries: string[]; + + @Column({ name: 'immigration_types', type: 'text', array: true, default: '{}' }) + immigrationTypes: string[]; + + @Column({ name: 'planned_timeline', type: 'varchar', length: 20, nullable: true }) + plannedTimeline: string | null; + + @Column({ name: 'primary_purpose', type: 'text', nullable: true }) + primaryPurpose: string | null; + + // 教育背景 + @Column({ name: 'highest_education', type: 'varchar', length: 20, nullable: true }) + highestEducation: string | null; + + @Column({ name: 'education_records', type: 'jsonb', default: '[]' }) + educationRecords: Record[]; + + // 工作经历 + @Column({ name: 'current_occupation', type: 'varchar', length: 100, nullable: true }) + currentOccupation: string | null; + + @Column({ name: 'total_work_years', type: 'int', nullable: true }) + totalWorkYears: number | null; + + @Column({ name: 'work_records', type: 'jsonb', default: '[]' }) + workRecords: Record[]; + + // 语言能力 + @Column({ name: 'language_scores', type: 'jsonb', default: '[]' }) + languageScores: Record[]; + + // 家庭信息 + @Column({ name: 'family_members', type: 'jsonb', default: '[]' }) + familyMembers: Record[]; + + // 资产信息 + @Column({ name: 'net_worth_range', type: 'varchar', length: 20, nullable: true }) + netWorthRange: string | null; + + @Column({ name: 'has_business_experience', type: 'boolean', default: false }) + hasBusinessExperience: boolean; + + @Column({ name: 'business_years', type: 'int', nullable: true }) + businessYears: number | null; + + // 其他 + @Column({ name: 'has_overseas_experience', type: 'boolean', default: false }) + hasOverseasExperience: boolean; + + @Column({ name: 'has_criminal_record', type: 'boolean', nullable: true }) + hasCriminalRecord: boolean | null; + + @Column({ name: 'has_health_issues', type: 'boolean', nullable: true }) + hasHealthIssues: boolean | null; + + @Column({ name: 'additional_notes', type: 'text', nullable: true }) + additionalNotes: string | null; + + // 完成度 + @Column({ name: 'completion_percentage', type: 'int', default: 0 }) + completionPercentage: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/packages/services/user-service/src/user/user.module.ts b/packages/services/user-service/src/user/user.module.ts index f6b6051..52f5e90 100644 --- a/packages/services/user-service/src/user/user.module.ts +++ b/packages/services/user-service/src/user/user.module.ts @@ -1,22 +1,66 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; + +// ORM Entities import { UserORM } from '../infrastructure/database/postgres/entities/user.orm'; -import { UserPostgresRepository } from '../adapters/outbound/persistence/user-postgres.repository'; +import { UserProfileORM } from '../infrastructure/database/postgres/entities/user-profile.orm'; +import { UserContactORM } from '../infrastructure/database/postgres/entities/user-contact.orm'; + +// Repository Interfaces import { USER_REPOSITORY } from '../domain/repositories/user.repository.interface'; +import { USER_PROFILE_REPOSITORY } from '../domain/repositories/user-profile.repository.interface'; +import { USER_CONTACT_REPOSITORY } from '../domain/repositories/user-contact.repository.interface'; + +// Repository Implementations +import { UserPostgresRepository } from '../adapters/outbound/persistence/user-postgres.repository'; +import { UserProfilePostgresRepository } from '../adapters/outbound/persistence/user-profile-postgres.repository'; +import { UserContactPostgresRepository } from '../adapters/outbound/persistence/user-contact-postgres.repository'; + +// Services import { UserService } from '../application/services/user.service'; +import { UserProfileService } from '../application/services/user-profile.service'; +import { UserContactService } from '../application/services/user-contact.service'; + +// Controllers import { UserController } from '../adapters/inbound/user.controller'; import { AdminUserController } from '../adapters/inbound/admin-user.controller'; +import { UserProfileController } from '../adapters/inbound/user-profile.controller'; +import { UserContactController } from '../adapters/inbound/user-contact.controller'; @Module({ - imports: [TypeOrmModule.forFeature([UserORM])], - controllers: [UserController, AdminUserController], + imports: [ + TypeOrmModule.forFeature([UserORM, UserProfileORM, UserContactORM]), + ], + controllers: [ + UserController, + AdminUserController, + UserProfileController, + UserContactController, + ], providers: [ UserService, + UserProfileService, + UserContactService, { provide: USER_REPOSITORY, useClass: UserPostgresRepository, }, + { + provide: USER_PROFILE_REPOSITORY, + useClass: UserProfilePostgresRepository, + }, + { + provide: USER_CONTACT_REPOSITORY, + useClass: UserContactPostgresRepository, + }, + ], + exports: [ + UserService, + UserProfileService, + UserContactService, + USER_REPOSITORY, + USER_PROFILE_REPOSITORY, + USER_CONTACT_REPOSITORY, ], - exports: [UserService, USER_REPOSITORY], }) export class UserModule {} diff --git a/scripts/migrations/20260125_add_user_profile.sql b/scripts/migrations/20260125_add_user_profile.sql new file mode 100644 index 0000000..b174209 --- /dev/null +++ b/scripts/migrations/20260125_add_user_profile.sql @@ -0,0 +1,142 @@ +-- =========================================== +-- 用户档案迁移脚本 +-- 添加用户移民档案和联系方式表 +-- =========================================== + +-- =========================================== +-- 用户档案表 (user_profiles) +-- 存储用户移民相关的详细信息 +-- =========================================== +CREATE TABLE IF NOT EXISTS user_profiles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL UNIQUE, + + -- 基本信息 + full_name VARCHAR(100), + birth_date DATE, + nationality VARCHAR(2), -- ISO 3166-1 alpha-2 + current_country VARCHAR(2), + current_city VARCHAR(100), + marital_status VARCHAR(20), -- SINGLE, MARRIED, DIVORCED, WIDOWED + + -- 移民意向 + target_countries TEXT[] DEFAULT '{}', + immigration_types TEXT[] DEFAULT '{}', -- SKILLED, INVESTMENT, BUSINESS, STUDY, FAMILY, etc. + planned_timeline VARCHAR(20), -- WITHIN_6M, WITHIN_1Y, 1_2Y, 2_3Y, NO_PLAN + primary_purpose TEXT, + + -- 教育背景 + highest_education VARCHAR(20), -- HIGH_SCHOOL, ASSOCIATE, BACHELOR, MASTER, DOCTORATE + education_records JSONB DEFAULT '[]', + + -- 工作经历 + current_occupation VARCHAR(100), + total_work_years INT, + work_records JSONB DEFAULT '[]', + + -- 语言能力 + language_scores JSONB DEFAULT '[]', + + -- 家庭信息 + family_members JSONB DEFAULT '[]', + + -- 资产信息 + net_worth_range VARCHAR(20), -- UNDER_500K, 500K_1M, 1M_5M, 5M_10M, OVER_10M + has_business_experience BOOLEAN DEFAULT FALSE, + business_years INT, + + -- 其他 + has_overseas_experience BOOLEAN DEFAULT FALSE, + has_criminal_record BOOLEAN, + has_health_issues BOOLEAN, + additional_notes TEXT, + + -- 完成度 + completion_percentage INT DEFAULT 0 CHECK (completion_percentage >= 0 AND completion_percentage <= 100), + + -- 时间戳 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE user_profiles IS '用户档案表 - 存储用户移民相关的详细信息'; +COMMENT ON COLUMN user_profiles.nationality IS '国籍,ISO 3166-1 alpha-2 代码'; +COMMENT ON COLUMN user_profiles.target_countries IS '目标移民国家列表,ISO 3166-1 alpha-2 代码'; +COMMENT ON COLUMN user_profiles.immigration_types IS '感兴趣的移民类型列表'; +COMMENT ON COLUMN user_profiles.education_records IS '教育经历JSON数组,包含学历、专业、院校、毕业年份等'; +COMMENT ON COLUMN user_profiles.work_records IS '工作经历JSON数组,包含职位、公司、行业、时间等'; +COMMENT ON COLUMN user_profiles.language_scores IS '语言成绩JSON数组,包含雅思、托福、TEF等'; +COMMENT ON COLUMN user_profiles.family_members IS '家庭成员JSON数组,包含配偶、子女等信息'; +COMMENT ON COLUMN user_profiles.completion_percentage IS '档案完成度百分比'; + +-- 索引 +CREATE INDEX idx_user_profiles_tenant ON user_profiles(tenant_id); +CREATE INDEX idx_user_profiles_user ON user_profiles(user_id); +CREATE INDEX idx_user_profiles_nationality ON user_profiles(tenant_id, nationality); +CREATE INDEX idx_user_profiles_target_countries ON user_profiles USING GIN(target_countries); +CREATE INDEX idx_user_profiles_immigration_types ON user_profiles USING GIN(immigration_types); +CREATE INDEX idx_user_profiles_completion ON user_profiles(tenant_id, completion_percentage DESC); + +-- 更新时间触发器 +CREATE TRIGGER update_user_profiles_updated_at + BEFORE UPDATE ON user_profiles + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- =========================================== +-- 用户联系方式表 (user_contacts) +-- 存储用户绑定的联系方式,支持通知推送 +-- =========================================== +CREATE TABLE IF NOT EXISTS user_contacts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + + -- 联系方式类型: EMAIL, WECHAT, WHATSAPP, TELEGRAM, LINE + type VARCHAR(20) NOT NULL, + -- 联系方式值(邮箱地址、openid、手机号等) + value VARCHAR(255) NOT NULL, + -- 显示名称(如微信昵称) + display_name VARCHAR(100), + + -- 验证状态 + is_verified BOOLEAN DEFAULT FALSE, + verified_at TIMESTAMP WITH TIME ZONE, + + -- 通知设置 (付费功能) + notification_enabled BOOLEAN DEFAULT FALSE, + enabled_notification_types TEXT[] DEFAULT '{}', -- POLICY_UPDATE, DEADLINE_REMINDER, APPOINTMENT, CASE_UPDATE, PROMOTION + + -- 验证相关 + verification_code VARCHAR(10), + verification_expires_at TIMESTAMP WITH TIME ZONE, + + -- 时间戳 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- 同一用户同一类型只能有一个联系方式 + UNIQUE(tenant_id, user_id, type) +); + +COMMENT ON TABLE user_contacts IS '用户联系方式表 - 存储用户绑定的联系方式,支持通知推送'; +COMMENT ON COLUMN user_contacts.type IS '联系方式类型: EMAIL邮箱, WECHAT微信, WHATSAPP, TELEGRAM, LINE'; +COMMENT ON COLUMN user_contacts.notification_enabled IS '是否启用通知推送(付费功能)'; +COMMENT ON COLUMN user_contacts.enabled_notification_types IS '启用的通知类型列表'; + +-- 索引 +CREATE INDEX idx_user_contacts_tenant ON user_contacts(tenant_id); +CREATE INDEX idx_user_contacts_user ON user_contacts(tenant_id, user_id); +CREATE INDEX idx_user_contacts_type ON user_contacts(tenant_id, user_id, type); +CREATE INDEX idx_user_contacts_verified ON user_contacts(tenant_id, is_verified); +CREATE INDEX idx_user_contacts_notification ON user_contacts(tenant_id, notification_enabled) WHERE notification_enabled = TRUE; + +-- 更新时间触发器 +CREATE TRIGGER update_user_contacts_updated_at + BEFORE UPDATE ON user_contacts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- =========================================== +-- 更新 init-db.sql 需要的默认数据(如果需要) +-- =========================================== +-- 暂无默认数据需要插入