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 './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';
|
||||||
|
|
|
||||||
|
|
@ -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 './user.service';
|
||||||
export * from './auth.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 { 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 {}
|
||||||
|
|
|
||||||
|
|
@ -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