Spaces:
Running
Running
| import { | |
| ConflictException, | |
| Injectable, | |
| Logger, | |
| NotFoundException, | |
| UnauthorizedException, | |
| } from "@nestjs/common"; | |
| import { PrismaService } from "../database/prisma.service"; | |
| import { v4 as uuidv4 } from "uuid"; | |
| import { JwtService } from "@nestjs/jwt"; | |
| import * as crypto from "crypto"; | |
| import { EmailService } from "../email/email.service"; | |
| export interface FacilityLinkTokenPayload { | |
| facilityId: string; | |
| facilityName: string; | |
| /** jti — unique token ID so each QR can only be used once */ | |
| jti: string; | |
| /** standard JWT exp — set to 48h */ | |
| exp?: number; | |
| } | |
| () | |
| export class FacilitiesService { | |
| private readonly logger = new Logger(FacilitiesService.name); | |
| constructor( | |
| private prisma: PrismaService, | |
| private readonly jwtService: JwtService, | |
| private readonly emailService: EmailService, | |
| ) {} | |
| // ─── FACILITY CRUD ─────────────────────────────────────────────────────── | |
| async create(data: { | |
| organizationId?: string; | |
| name: string; | |
| address?: string; | |
| phone?: string; | |
| adminFirstName: string; | |
| adminLastName: string; | |
| adminEmail: string; | |
| adminPhone?: string; | |
| adminType?: string; | |
| }) { | |
| if (data.organizationId) { | |
| const organization = await this.prisma.organization.findUnique({ | |
| where: { id: data.organizationId }, | |
| }); | |
| if (!organization) { | |
| throw new NotFoundException("Organization not found"); | |
| } | |
| } | |
| // Check if admin email already exists | |
| const existingUser = await this.prisma.userAuth.findUnique({ | |
| where: { email: data.adminEmail.toLowerCase() }, | |
| }); | |
| if (existingUser) { | |
| throw new ConflictException("Admin email already exists"); | |
| } | |
| const facilityCode = uuidv4().split("-")[0].toUpperCase(); | |
| // Generate password setup token | |
| const setupToken = crypto.randomBytes(32).toString("hex"); | |
| const expires = new Date(); | |
| expires.setHours(expires.getHours() + 24); | |
| // Use transaction to ensure all operations succeed or fail together | |
| const facility = await this.prisma.$transaction(async (tx) => { | |
| // 1. Create facility | |
| const facility = await tx.facility.create({ | |
| data: { | |
| name: data.name, | |
| address: data.address, | |
| phone: data.phone, | |
| facilityCode, | |
| organizationId: data.organizationId ?? null, | |
| }, | |
| include: { | |
| organization: true, | |
| }, | |
| }); | |
| // 2. Create user auth (no password yet) | |
| const userAuth = await tx.userAuth.create({ | |
| data: { | |
| email: data.adminEmail.toLowerCase(), | |
| phone: data.adminPhone, | |
| role: "FACILITY_ADMIN", | |
| passwordSetupToken: setupToken, | |
| passwordSetupExpiresAt: expires, | |
| status: "PENDING", | |
| }, | |
| }); | |
| // 3. Create healthcare worker | |
| await tx.healthcareWorker.create({ | |
| data: { | |
| userId: userAuth.id, | |
| facilityId: facility.id, | |
| type: (data.adminType || "COMMUNITY_HEALTH_WORKER") as any, | |
| firstName: data.adminFirstName, | |
| lastName: data.adminLastName, | |
| }, | |
| }); | |
| return facility; | |
| }); | |
| // 4. Send welcome email asynchronously | |
| this.emailService | |
| .sendFacilityAdminWelcome( | |
| data.adminEmail, | |
| `${data.adminFirstName} ${data.adminLastName}`, | |
| facility.name, | |
| setupToken, | |
| ) | |
| .catch((err) => | |
| this.logger.error( | |
| `Failed to send welcome email to ${data.adminEmail}`, | |
| err, | |
| ), | |
| ); | |
| return facility; | |
| } | |
| async findAll() { | |
| return this.prisma.facility.findMany({ | |
| include: { | |
| organization: true, | |
| }, | |
| orderBy: { | |
| createdAt: "desc", | |
| }, | |
| }); | |
| } | |
| async findOne(id: string) { | |
| const facility = await this.prisma.facility.findUnique({ | |
| where: { id }, | |
| include: { | |
| organization: true, | |
| }, | |
| }); | |
| if (!facility) { | |
| throw new NotFoundException("Facility not found"); | |
| } | |
| return facility; | |
| } | |
| async update( | |
| id: string, | |
| data: { | |
| organizationId?: string | null; | |
| name?: string; | |
| address?: string; | |
| phone?: string; | |
| isActive?: boolean; | |
| }, | |
| ) { | |
| if (data.organizationId) { | |
| const organization = await this.prisma.organization.findUnique({ | |
| where: { | |
| id: data.organizationId, | |
| }, | |
| }); | |
| if (!organization) { | |
| throw new NotFoundException("Organization not found"); | |
| } | |
| } | |
| return this.prisma.facility.update({ | |
| where: { id }, | |
| data: { | |
| ...data, | |
| }, | |
| include: { | |
| organization: true, | |
| }, | |
| }); | |
| } | |
| async deactivate(id: string) { | |
| return this.prisma.facility.update({ | |
| where: { id }, | |
| data: { isActive: false }, | |
| }); | |
| } | |
| // ─── QR FACILITY LINKING ───────────────────────────────────────────────── | |
| /** | |
| * Called by a health worker on the dashboard. | |
| * Returns a short-lived signed JWT that gets encoded into a QR code. | |
| * | |
| * Uses FACILITY_LINK_SECRET — separate from the auth JWT secret so a | |
| * leaked link token cannot be replayed as an auth token. | |
| * | |
| * Expiry: 48 hours. | |
| */ | |
| async generateLinkToken(healthWorkerId: string): Promise<{ | |
| token: string; | |
| facilityId: string; | |
| facilityName: string; | |
| expiresAt: Date; | |
| }> { | |
| const worker = await this.prisma.healthcareWorker.findUnique({ | |
| where: { id: healthWorkerId }, | |
| include: { facility: true }, | |
| }); | |
| if (!worker || worker.isSuspended) { | |
| throw new UnauthorizedException("Health worker not found or suspended"); | |
| } | |
| const jti = uuidv4(); | |
| const expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000); | |
| const payload: FacilityLinkTokenPayload = { | |
| facilityId: worker.facilityId, | |
| facilityName: worker.facility.name, | |
| jti, | |
| }; | |
| const token = this.jwtService.sign(payload, { | |
| secret: process.env.FACILITY_LINK_SECRET, | |
| expiresIn: "48h", | |
| }); | |
| this.logger.log( | |
| `Link token generated for facility ${worker.facilityId} by worker ${healthWorkerId}`, | |
| ); | |
| return { | |
| token, | |
| facilityId: worker.facilityId, | |
| facilityName: worker.facility.name, | |
| expiresAt, | |
| }; | |
| } | |
| /** | |
| * Called by the patient app after scanning the QR code. | |
| * | |
| * Flow: | |
| * 1. Verify JWT signature and expiry | |
| * 2. Confirm facility is still active | |
| * 3. Check if Patient record already exists for this user: | |
| * - Same facility → idempotent success (alreadyLinked: true) | |
| * - Diff facility → ConflictException, must unlink first | |
| * - No record → create Patient record | |
| */ | |
| async linkPatientViaQR( | |
| userId: string, | |
| token: string, | |
| ): Promise<{ | |
| facilityId: string; | |
| facilityName: string; | |
| patientCode: string; | |
| alreadyLinked: boolean; | |
| }> { | |
| // 1. Verify token signature + expiry | |
| let payload: FacilityLinkTokenPayload; | |
| try { | |
| payload = this.jwtService.verify<FacilityLinkTokenPayload>(token, { | |
| secret: process.env.FACILITY_LINK_SECRET, | |
| }); | |
| } catch { | |
| throw new UnauthorizedException( | |
| "This QR code has expired or is invalid. Ask your health worker to generate a new one.", | |
| ); | |
| } | |
| const { facilityId, facilityName } = payload; | |
| // 2. Confirm facility is active | |
| const facility = await this.prisma.facility.findUnique({ | |
| where: { id: facilityId }, | |
| }); | |
| if (!facility || !facility.isActive) { | |
| throw new NotFoundException("Facility not found or no longer active"); | |
| } | |
| // 3. Get patient's auth record + any existing Patient row | |
| const userAuth = await this.prisma.userAuth.findUnique({ | |
| where: { id: userId }, | |
| include: { patient: true }, | |
| }); | |
| if (!userAuth) throw new NotFoundException("User not found"); | |
| // 4. Handle existing Patient record | |
| if (userAuth.patient) { | |
| if (userAuth.patient.facilityId === facilityId) { | |
| return { | |
| facilityId, | |
| facilityName, | |
| patientCode: userAuth.patient.patientId, | |
| alreadyLinked: true, | |
| }; | |
| } | |
| throw new ConflictException( | |
| "You are already linked to another facility. Please contact your current facility to unlink first.", | |
| ); | |
| } | |
| // 5. Create Patient record — pull gestational age from UserProfile if available | |
| const profile = await this.prisma.userProfile.findUnique({ | |
| where: { userId }, | |
| }); | |
| const patientCode = uuidv4().split("-")[0].toUpperCase(); | |
| const patient = await this.prisma.patient.create({ | |
| data: { | |
| userId, | |
| facilityId, | |
| patientId: patientCode, | |
| // Self-registered users have no name — health worker can update from dashboard | |
| firstName: userAuth.email?.split("@")[0] ?? "Unknown", | |
| lastName: "", | |
| phone: userAuth.phone ?? "", | |
| age: 0, | |
| gestationalAge: profile?.pregnancyWeeks ?? 0, | |
| qrCode: patientCode, | |
| }, | |
| }); | |
| this.logger.log( | |
| `Patient ${userId} linked to facility ${facilityId} via QR`, | |
| ); | |
| return { | |
| facilityId, | |
| facilityName, | |
| patientCode: patient.patientId, | |
| alreadyLinked: false, | |
| }; | |
| } | |
| /** | |
| * Get a patient's current facility link status. | |
| * Returns { linked: false } if not linked. | |
| */ | |
| async getLinkStatus(userId: string) { | |
| const patient = await this.prisma.patient.findUnique({ | |
| where: { userId }, | |
| include: { | |
| facility: { select: { name: true, address: true, phone: true } }, | |
| }, | |
| }); | |
| if (!patient || !patient.facility) { | |
| return { | |
| linked: false, | |
| }; | |
| } | |
| return { | |
| linked: !!patient.facility, | |
| facilityId: patient.facilityId, | |
| facilityName: patient.facility?.name ?? null, | |
| facilityAddress: patient.facility?.address ?? null, | |
| facilityPhone: patient.facility?.phone ?? null, | |
| patientCode: patient.patientId, | |
| linkedAt: patient.createdAt, | |
| }; | |
| } | |
| /** | |
| * Unlink a patient from their facility. | |
| * Deletes the Patient record only — UserAuth + UserProfile are preserved. | |
| */ | |
| async unlinkPatient(userId: string): Promise<void> { | |
| const patient = await this.prisma.patient.findUnique({ | |
| where: { userId }, | |
| }); | |
| if (!patient) { | |
| throw new NotFoundException("No facility link found for this user"); | |
| } | |
| await this.prisma.patient.delete({ where: { userId } }); | |
| this.logger.log( | |
| `Patient ${userId} unlinked from facility ${patient.facilityId}`, | |
| ); | |
| } | |
| } | |