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; } @Injectable() 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(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 { 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}`, ); } }