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:
parent
92ee490a57
commit
7975982fc3
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
export * from './user.service';
|
||||
export * from './auth.service';
|
||||
export * from './user-profile.service';
|
||||
export * from './user-contact.service';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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');
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 需要的默认数据(如果需要)
|
||||
-- ===========================================
|
||||
-- 暂无默认数据需要插入
|
||||
Loading…
Reference in New Issue