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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-25 19:32:17 -08:00
parent 92ee490a57
commit 7975982fc3
18 changed files with 2205 additions and 4 deletions

View File

@ -1,3 +1,5 @@
export * from './user.controller'; export * from './user.controller';
export * from './auth.controller'; export * from './auth.controller';
export * from './admin-user.controller'; export * from './admin-user.controller';
export * from './user-profile.controller';
export * from './user-contact.controller';

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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<UserContactEntity, UserContactORM>
implements IUserContactRepository
{
constructor(
@InjectRepository(UserContactORM) repo: Repository<UserContactORM>,
tenantContext: TenantContextService,
) {
super(repo, tenantContext);
}
async save(contact: UserContactEntity): Promise<void> {
const orm = this.toORM(contact);
orm.tenantId = this.getTenantId();
await this.repo.save(orm);
}
async findById(id: string): Promise<UserContactEntity | null> {
const orm = await this.findOneWithTenant({ id } as any);
return orm ? this.toEntity(orm) : null;
}
async findByUserIdAndType(userId: string, type: ContactType): Promise<UserContactEntity | null> {
const orm = await this.findOneWithTenant({ userId, type } as any);
return orm ? this.toEntity(orm) : null;
}
async findByUserId(userId: string): Promise<UserContactEntity[]> {
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<UserContactEntity[]> {
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<void> {
const orm = this.toORM(contact);
orm.tenantId = this.getTenantId();
await this.repo.save(orm);
}
async delete(id: string): Promise<void> {
await this.repo.delete({ id, tenantId: this.getTenantId() } as any);
}
async findByNotificationType(
notificationType: NotificationType,
options?: { limit?: number; offset?: number },
): Promise<UserContactEntity[]> {
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<Record<ContactType, number>> {
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, number> = {
[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,
});
}
}

View File

@ -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<UserProfileEntity, UserProfileORM>
implements IUserProfileRepository
{
constructor(
@InjectRepository(UserProfileORM) repo: Repository<UserProfileORM>,
tenantContext: TenantContextService,
) {
super(repo, tenantContext);
}
async save(profile: UserProfileEntity): Promise<void> {
const orm = this.toORM(profile);
orm.tenantId = this.getTenantId();
await this.repo.save(orm);
}
async findById(id: string): Promise<UserProfileEntity | null> {
const orm = await this.findOneWithTenant({ id } as any);
return orm ? this.toEntity(orm) : null;
}
async findByUserId(userId: string): Promise<UserProfileEntity | null> {
const orm = await this.findOneWithTenant({ userId } as any);
return orm ? this.toEntity(orm) : null;
}
async update(profile: UserProfileEntity): Promise<void> {
const orm = this.toORM(profile);
orm.tenantId = this.getTenantId();
await this.repo.save(orm);
}
async delete(id: string): Promise<void> {
await this.repo.delete({ id, tenantId: this.getTenantId() } as any);
}
async findByTargetCountry(
countryCode: string,
options?: { limit?: number; offset?: number },
): Promise<UserProfileEntity[]> {
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<string, unknown>[];
orm.currentOccupation = entity.currentOccupation;
orm.totalWorkYears = entity.totalWorkYears;
orm.workRecords = entity.workRecords as unknown as Record<string, unknown>[];
orm.languageScores = entity.languageScores as unknown as Record<string, unknown>[];
orm.familyMembers = entity.familyMembers as unknown as Record<string, unknown>[];
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,
});
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,2 +1,4 @@
export * from './user.service'; export * from './user.service';
export * from './auth.service'; export * from './auth.service';
export * from './user-profile.service';
export * from './user-contact.service';

View File

@ -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<UserContactEntity> {
// 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<UserContactEntity[]> {
return this.contactRepo.findByUserId(userId);
}
async getVerifiedContacts(userId: string): Promise<UserContactEntity[]> {
return this.contactRepo.findVerifiedByUserId(userId);
}
async getContact(userId: string, type: ContactType): Promise<UserContactEntity> {
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<UserContactEntity> {
const contact = await this.getContact(userId, type);
contact.updateValue(value);
await this.contactRepo.update(contact);
return contact;
}
async deleteContact(userId: string, type: ContactType): Promise<void> {
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<UserContactEntity> {
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<UserContactEntity> {
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<UserContactEntity> {
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<UserContactEntity> {
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<UserContactEntity> {
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<UserContactEntity[]> {
return this.contactRepo.findByNotificationType(notificationType, options);
}
async getVerifiedContactStats(): Promise<Record<ContactType, number>> {
return this.contactRepo.countVerifiedByType();
}
}

View File

@ -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<UserProfileEntity> {
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<UserProfileEntity> {
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<UserProfileEntity> {
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<UserProfileEntity> {
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<UserProfileEntity> {
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<UserProfileEntity> {
const profile = await this.getOrCreateProfile(userId);
profile.updateWorkExperience(data);
await this.profileRepo.update(profile);
return profile;
}
async updateLanguageScores(
userId: string,
scores: LanguageScore[],
): Promise<UserProfileEntity> {
const profile = await this.getOrCreateProfile(userId);
profile.updateLanguageScores(scores);
await this.profileRepo.update(profile);
return profile;
}
async updateFamilyMembers(
userId: string,
members: FamilyMember[],
): Promise<UserProfileEntity> {
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<UserProfileEntity> {
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<UserProfileEntity[]> {
return this.profileRepo.findByTargetCountry(countryCode, options);
}
async getCompletionStats(): Promise<{ low: number; medium: number; high: number }> {
return this.profileRepo.countByCompletionRange();
}
}

View File

@ -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();
}
}

View File

@ -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<UserProfileEntity> & { 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;
}
}

View File

@ -0,0 +1,53 @@
import { UserContactEntity, ContactType, NotificationType } from '../entities/user-contact.entity';
export interface IUserContactRepository {
/**
*
*/
save(contact: UserContactEntity): Promise<void>;
/**
* ID查找
*/
findById(id: string): Promise<UserContactEntity | null>;
/**
* ID和类型查找
*/
findByUserIdAndType(userId: string, type: ContactType): Promise<UserContactEntity | null>;
/**
*
*/
findByUserId(userId: string): Promise<UserContactEntity[]>;
/**
*
*/
findVerifiedByUserId(userId: string): Promise<UserContactEntity[]>;
/**
*
*/
update(contact: UserContactEntity): Promise<void>;
/**
*
*/
delete(id: string): Promise<void>;
/**
*
*/
findByNotificationType(
notificationType: NotificationType,
options?: { limit?: number; offset?: number },
): Promise<UserContactEntity[]>;
/**
*
*/
countVerifiedByType(): Promise<Record<ContactType, number>>;
}
export const USER_CONTACT_REPOSITORY = Symbol('USER_CONTACT_REPOSITORY');

View File

@ -0,0 +1,47 @@
import { UserProfileEntity } from '../entities/user-profile.entity';
export interface IUserProfileRepository {
/**
*
*/
save(profile: UserProfileEntity): Promise<void>;
/**
* ID查找档案
*/
findById(id: string): Promise<UserProfileEntity | null>;
/**
* ID查找档案
*/
findByUserId(userId: string): Promise<UserProfileEntity | null>;
/**
*
*/
update(profile: UserProfileEntity): Promise<void>;
/**
*
*/
delete(id: string): Promise<void>;
/**
*
*/
findByTargetCountry(countryCode: string, options?: {
limit?: number;
offset?: number;
}): Promise<UserProfileEntity[]>;
/**
*
*/
countByCompletionRange(): Promise<{
low: number; // 0-30%
medium: number; // 30-70%
high: number; // 70-100%
}>;
}
export const USER_PROFILE_REPOSITORY = Symbol('USER_PROFILE_REPOSITORY');

View File

@ -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;
}

View File

@ -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<string, unknown>[];
// 工作经历
@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<string, unknown>[];
// 语言能力
@Column({ name: 'language_scores', type: 'jsonb', default: '[]' })
languageScores: Record<string, unknown>[];
// 家庭信息
@Column({ name: 'family_members', type: 'jsonb', default: '[]' })
familyMembers: Record<string, unknown>[];
// 资产信息
@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;
}

View File

@ -1,22 +1,66 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
// ORM Entities
import { UserORM } from '../infrastructure/database/postgres/entities/user.orm'; 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_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 { 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 { UserController } from '../adapters/inbound/user.controller';
import { AdminUserController } from '../adapters/inbound/admin-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({ @Module({
imports: [TypeOrmModule.forFeature([UserORM])], imports: [
controllers: [UserController, AdminUserController], TypeOrmModule.forFeature([UserORM, UserProfileORM, UserContactORM]),
],
controllers: [
UserController,
AdminUserController,
UserProfileController,
UserContactController,
],
providers: [ providers: [
UserService, UserService,
UserProfileService,
UserContactService,
{ {
provide: USER_REPOSITORY, provide: USER_REPOSITORY,
useClass: UserPostgresRepository, 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 {} export class UserModule {}

View File

@ -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 需要的默认数据(如果需要)
-- ===========================================
-- 暂无默认数据需要插入