MaternAlert / src /facilities /facilities.service.ts
Auspicious14's picture
feat: add staff onboarding, password setup and welcome emails
131aa2a
Raw
History Blame Contribute Delete
10.8 kB
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<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}`,
);
}
}