diff --git a/prisma.config.ts b/prisma.config.ts index 831a20f..f4b02bd 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ schema: "prisma/schema.prisma", migrations: { path: "prisma/migrations", + seed: "node --env-file=.env --loader ts-node/esm prisma/seed.ts", }, datasource: { url: process.env["DATABASE_URL"], diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..635fce6 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,301 @@ +import { PrismaPg } from '@prisma/adapter-pg'; +import { randomBytes, scrypt } from 'node:crypto'; +import { promisify } from 'node:util'; +import { PrismaClient } from '../src/generated/prisma/client.js'; +import { UserRole } from '../src/generated/prisma/enums.js'; + +const scryptAsync = promisify(scrypt); + +const TEST_PASSWORD = 'Test123456'; +const HOSPITAL_CODE = 'DEMO_HOSP_001'; +const HOSPITAL_NAME = 'Demo Hospital'; +const DEPARTMENT_NAME = 'Cardiology'; +const MEDICAL_GROUP_NAME = 'Group A'; + +interface SeedUserInput { + phone: string; + name: string; + role: UserRole; + hospitalId: number | null; + departmentId: number | null; + medicalGroupId: number | null; + managerId: number | null; +} + +interface PatientInput { + name: string; + hospitalId: number; + departmentId: number | null; + medicalGroupId: number | null; + doctorId: number; +} + +function requireDatabaseUrl(): string { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error('DATABASE_URL is required. Run with `node --env-file=.env ...`.'); + } + return url; +} + +async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('hex'); + const derivedKey = (await scryptAsync(password, salt, 64)) as Buffer; + return `${salt}:${derivedKey.toString('hex')}`; +} + +async function upsertUser( + prisma: PrismaClient, + user: SeedUserInput, + passwordHash: string, +) { + return prisma.user.upsert({ + where: { phone: user.phone }, + create: { + phone: user.phone, + passwordHash, + name: user.name, + role: user.role, + hospitalId: user.hospitalId, + departmentId: user.departmentId, + medicalGroupId: user.medicalGroupId, + managerId: user.managerId, + isActive: true, + }, + update: { + passwordHash, + name: user.name, + role: user.role, + hospitalId: user.hospitalId, + departmentId: user.departmentId, + medicalGroupId: user.medicalGroupId, + managerId: user.managerId, + isActive: true, + }, + select: { + id: true, + role: true, + phone: true, + name: true, + }, + }); +} + +async function upsertPatientByNaturalKey( + prisma: PrismaClient, + patient: PatientInput, +) { + const existing = await prisma.patient.findFirst({ + where: { + name: patient.name, + hospitalId: patient.hospitalId, + doctorId: patient.doctorId, + }, + select: { id: true }, + }); + + if (existing) { + return prisma.patient.update({ + where: { id: existing.id }, + data: { + departmentId: patient.departmentId, + medicalGroupId: patient.medicalGroupId, + }, + select: { id: true, name: true }, + }); + } + + return prisma.patient.create({ + data: patient, + select: { id: true, name: true }, + }); +} + +async function main() { + const adapter = new PrismaPg({ connectionString: requireDatabaseUrl() }); + const prisma = new PrismaClient({ adapter }); + + try { + const passwordHash = await hashPassword(TEST_PASSWORD); + + const hospital = await prisma.hospital.upsert({ + where: { code: HOSPITAL_CODE }, + create: { + code: HOSPITAL_CODE, + name: HOSPITAL_NAME, + }, + update: { + name: HOSPITAL_NAME, + }, + select: { id: true, name: true, code: true }, + }); + + const department = await prisma.department.upsert({ + where: { + hospitalId_name: { + hospitalId: hospital.id, + name: DEPARTMENT_NAME, + }, + }, + create: { + name: DEPARTMENT_NAME, + hospitalId: hospital.id, + }, + update: {}, + select: { id: true, name: true }, + }); + + const medicalGroup = await prisma.medicalGroup.upsert({ + where: { + departmentId_name: { + departmentId: department.id, + name: MEDICAL_GROUP_NAME, + }, + }, + create: { + name: MEDICAL_GROUP_NAME, + departmentId: department.id, + }, + update: {}, + select: { id: true, name: true }, + }); + + const systemAdmin = await upsertUser( + prisma, + { + phone: '+8613800000001', + name: 'System Admin', + role: UserRole.SYSTEM_ADMIN, + hospitalId: null, + departmentId: null, + medicalGroupId: null, + managerId: null, + }, + passwordHash, + ); + + const hospitalAdmin = await upsertUser( + prisma, + { + phone: '+8613800000002', + name: 'Hospital Admin', + role: UserRole.HOSPITAL_ADMIN, + hospitalId: hospital.id, + departmentId: null, + medicalGroupId: null, + managerId: null, + }, + passwordHash, + ); + + const director = await upsertUser( + prisma, + { + phone: '+8613800000003', + name: 'Director', + role: UserRole.DIRECTOR, + hospitalId: hospital.id, + departmentId: department.id, + medicalGroupId: null, + managerId: hospitalAdmin.id, + }, + passwordHash, + ); + + const teamLead = await upsertUser( + prisma, + { + phone: '+8613800000004', + name: 'Team Lead', + role: UserRole.TEAM_LEAD, + hospitalId: hospital.id, + departmentId: department.id, + medicalGroupId: medicalGroup.id, + managerId: director.id, + }, + passwordHash, + ); + + const doctor = await upsertUser( + prisma, + { + phone: '+8613800000005', + name: 'Doctor', + role: UserRole.DOCTOR, + hospitalId: hospital.id, + departmentId: department.id, + medicalGroupId: medicalGroup.id, + managerId: teamLead.id, + }, + passwordHash, + ); + + const engineer = await upsertUser( + prisma, + { + phone: '+8613800000006', + name: 'Engineer', + role: UserRole.ENGINEER, + hospitalId: hospital.id, + departmentId: null, + medicalGroupId: null, + managerId: hospitalAdmin.id, + }, + passwordHash, + ); + + await upsertPatientByNaturalKey(prisma, { + name: 'Patient Alpha', + hospitalId: hospital.id, + departmentId: department.id, + medicalGroupId: medicalGroup.id, + doctorId: doctor.id, + }); + await upsertPatientByNaturalKey(prisma, { + name: 'Patient Beta', + hospitalId: hospital.id, + departmentId: department.id, + medicalGroupId: medicalGroup.id, + doctorId: doctor.id, + }); + + await prisma.engineerHospitalAssignment.upsert({ + where: { + hospitalId_engineerId: { + hospitalId: hospital.id, + engineerId: engineer.id, + }, + }, + create: { + hospitalId: hospital.id, + engineerId: engineer.id, + assignedById: systemAdmin.id, + }, + update: { + assignedById: systemAdmin.id, + }, + select: { id: true }, + }); + + console.log(`Seed completed for hospital ${hospital.code} (${hospital.name}).`); + console.log('Test password for all seeded users:', TEST_PASSWORD); + console.table( + [systemAdmin, hospitalAdmin, director, teamLead, doctor, engineer].map( + (user) => ({ + role: user.role, + phone: user.phone, + name: user.name ?? '', + password: TEST_PASSWORD, + }), + ), + ); + console.log('No mini-program or official-account openId was pre-seeded.'); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error) => { + console.error('Seed failed:', error); + process.exit(1); +}); diff --git a/src/auth/dto/change-password.dto.ts b/src/auth/dto/change-password.dto.ts index 78a832e..55d18e4 100644 --- a/src/auth/dto/change-password.dto.ts +++ b/src/auth/dto/change-password.dto.ts @@ -1,3 +1,4 @@ +import { ApiProperty } from '@nestjs/swagger'; import { Matches } from 'class-validator'; export class ChangePasswordDto { @@ -5,11 +6,19 @@ export class ChangePasswordDto { @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { message: '密码至少8位,且包含字母和数字', }) + @ApiProperty({ + description: 'Current password of the account.', + example: 'Test123456', + }) oldPassword: string; // 新密码使用与注册一致的安全策略。 @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { message: '密码至少8位,且包含字母和数字', }) + @ApiProperty({ + description: 'New password, 8-64 chars with letters and numbers.', + example: 'NewTest123456', + }) newPassword: string; } diff --git a/src/auth/dto/login-phone.dto.ts b/src/auth/dto/login-phone.dto.ts index 34f80c9..e69e38f 100644 --- a/src/auth/dto/login-phone.dto.ts +++ b/src/auth/dto/login-phone.dto.ts @@ -1,13 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; import { Matches } from 'class-validator'; export class LoginPhoneDto { // 手机号登录入口字段。 @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) + @ApiProperty({ + description: 'Login phone number in E.164 format.', + example: '+8613800138000', + }) phone: string; // 登录密码,规则与注册保持一致。 @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { message: '密码至少8位,且包含字母和数字', }) + @ApiProperty({ + description: 'Password must be 8-64 chars and include letters and numbers.', + example: 'Test123456', + }) password: string; } diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts index 644712d..fca0ba3 100644 --- a/src/auth/dto/register.dto.ts +++ b/src/auth/dto/register.dto.ts @@ -1,3 +1,4 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsInt, IsOptional, @@ -10,53 +11,89 @@ import { export class RegisterDto { // 手机号是主登录标识。 @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) + @ApiProperty({ + description: 'Phone number used as login account (E.164 format).', + example: '+8613800138000', + }) phone: string; // 注册密码强度策略。 @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { message: '密码至少8位,且包含字母和数字', }) + @ApiProperty({ + description: 'Password must be 8-64 chars and include letters and numbers.', + example: 'Test123456', + }) password: string; // 个人展示名称。 @IsString() @IsOptional() @MaxLength(64) + @ApiPropertyOptional({ + description: 'Display name.', + example: 'Demo Doctor', + }) name?: string; // 组织归属:医院。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Hospital id to bind during registration.', + example: 1, + }) hospitalId?: number; // 组织归属:科室。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Department id to bind during registration.', + example: 1, + }) departmentId?: number; // 组织归属:小组。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Medical group id to bind during registration.', + example: 1, + }) medicalGroupId?: number; // 直属上级用户 ID。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Direct manager user id.', + example: 2, + }) managerId?: number; // 可选:注册时直接绑定小程序账号。 @IsString() @IsOptional() @MaxLength(128) + @ApiPropertyOptional({ + description: 'Optional mini-program openId.', + example: 'mini_open_id_xxx', + }) wechatMiniOpenId?: string; // 可选:注册时直接绑定服务号账号。 @IsString() @IsOptional() @MaxLength(128) + @ApiPropertyOptional({ + description: 'Optional official-account openId.', + example: 'official_open_id_xxx', + }) wechatOfficialOpenId?: string; } diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index 9b9d740..bb883d1 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -1,3 +1,4 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { UserRole } from '../../generated/prisma/enums.js'; import { IsBoolean, @@ -13,51 +14,88 @@ import { export class CreateUserDto { // 手机号作为唯一登录名。 @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) + @ApiProperty({ + description: 'User login phone number (E.164 format).', + example: '+8613800138000', + }) phone: string; // 管理端创建用户时直接设置初始密码。 @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { message: '密码至少8位,且包含字母和数字', }) + @ApiProperty({ + description: 'Initial password, 8-64 chars with letters and numbers.', + example: 'Test123456', + }) password: string; // 真实姓名用于业务展示。 @IsString() @IsOptional() @MaxLength(64) + @ApiPropertyOptional({ + description: 'Display name.', + example: 'Demo User', + }) name?: string; // 未传角色时由服务端默认成 DOCTOR。 @IsEnum(UserRole) @IsOptional() + @ApiPropertyOptional({ + description: 'Role of the user. Defaults to DOCTOR when omitted.', + enum: UserRole, + example: UserRole.DOCTOR, + }) role?: UserRole; // 组织归属:医院。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Hospital id.', + example: 1, + }) hospitalId?: number; // 组织归属:科室。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Department id.', + example: 1, + }) departmentId?: number; // 组织归属:小组。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Medical group id.', + example: 1, + }) medicalGroupId?: number; // 上下级关系:直属上级用户 ID。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Direct manager user id.', + example: 2, + }) managerId?: number; // 是否启用账号,默认 true。 @IsBoolean() @IsOptional() + @ApiPropertyOptional({ + description: 'Whether the account is active. Defaults to true.', + example: true, + }) isActive?: boolean; } diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index 9f7565e..216d8a6 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -1,3 +1,4 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; import { UserRole } from '../../generated/prisma/enums.js'; import { IsBoolean, @@ -14,45 +15,78 @@ export class UpdateUserDto { // 修改手机号。 @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) @IsOptional() + @ApiPropertyOptional({ + description: 'User login phone number (E.164 format).', + example: '+8613800138000', + }) phone?: string; // 修改姓名。 @IsString() @IsOptional() @MaxLength(64) + @ApiPropertyOptional({ + description: 'Display name.', + example: 'Updated User', + }) name?: string; // 修改角色(仅管理员可用)。 @IsEnum(UserRole) @IsOptional() + @ApiPropertyOptional({ + description: 'Role of the user.', + enum: UserRole, + example: UserRole.DOCTOR, + }) role?: UserRole; // 修改医院归属(仅管理员可用)。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Hospital id.', + example: 1, + }) hospitalId?: number; // 修改科室归属(仅管理员可用)。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Department id.', + example: 1, + }) departmentId?: number; // 修改小组归属(仅管理员可用)。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Medical group id.', + example: 1, + }) medicalGroupId?: number; // 修改直属上级(仅管理员可用)。 @IsInt() @Min(1) @IsOptional() + @ApiPropertyOptional({ + description: 'Direct manager user id.', + example: 2, + }) managerId?: number; // 启停账号(仅管理员可用)。 @IsBoolean() @IsOptional() + @ApiPropertyOptional({ + description: 'Whether account is active.', + example: true, + }) isActive?: boolean; }