diff --git a/components/schedule/SessionDetailModal.vue b/components/schedule/SessionDetailModal.vue index 9843a15..e7babe4 100644 --- a/components/schedule/SessionDetailModal.vue +++ b/components/schedule/SessionDetailModal.vue @@ -247,6 +247,78 @@ function handleAddNote(patientId: string) { noteModals.progressReport = true; } +// --------------------------------------------------------------- +// Create patient report (tests used + diagnosis) for session +// --------------------------------------------------------------- +interface TherapyReportRow { + id: string; + patientId: string; + deliveredAt: string | null; +} + +const reportsUrl = computed(() => + props.session ? `/api/session/${props.session.id}/reports` : "" +); + +const { data: sessionReports, refresh: refreshSessionReports } = await useFetch< + TherapyReportRow[] +>(reportsUrl, { + immediate: !!props.session, + default: () => [], +}); + +const reportsByPatientId = computed(() => { + const map = new Map(); + for (const report of sessionReports.value ?? []) { + if (report?.patientId) map.set(report.patientId, report); + } + return map; +}); + +function getReportForPatient(patientId: string): TherapyReportRow | undefined { + return reportsByPatientId.value.get(patientId); +} + +const createReportModalOpen = ref(false); +const reportPatientId = ref(""); + +function handleCreateReport(patientId: string) { + if (!props.session) return; + reportPatientId.value = patientId; + createReportModalOpen.value = true; +} + +async function handleReportSave(data: { + testsUsed: string; + diagnosis: string; +}) { + if (!props.session) return; + try { + await $fetch("/api/session/reports", { + method: "POST", + body: { + patientId: reportPatientId.value, + sessionId: props.session.id, + testsUsed: data.testsUsed, + diagnosis: data.diagnosis, + }, + }); + await refreshSessionReports(); + createReportModalOpen.value = false; + toast.add({ + title: t("report.submitSuccess"), + color: "success", + icon: "i-lucide-circle-check", + }); + } catch { + toast.add({ + title: t("report.submitError"), + color: "error", + icon: "i-lucide-triangle-alert", + }); + } +} + async function handleNoteSave(formData: Record) { const result = await saveTherapyNote( formData, @@ -691,6 +763,27 @@ const modalDescription = computed(() => > {{ t("profile.columns.addNote") }} + + + {{ t("report.createButton") }} + + v-model="noteModals.viewNote" :note="activeNote" /> + + diff --git a/components/therapy/CreateReportModal.vue b/components/therapy/CreateReportModal.vue new file mode 100644 index 0000000..46f70b7 --- /dev/null +++ b/components/therapy/CreateReportModal.vue @@ -0,0 +1,96 @@ + + + + diff --git a/composables/auth/useUserLinks.ts b/composables/auth/useUserLinks.ts index 3721009..7f5ea5b 100644 --- a/composables/auth/useUserLinks.ts +++ b/composables/auth/useUserLinks.ts @@ -59,6 +59,10 @@ export function useUserLinks() { to: "session-sessionsView", label: "Sessions", }); + legalRoutes.push({ + to: "report-reportsView", + label: "Reports", + }); } if (val[AP.ADMIN]) { diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 35d6060..4b6539f 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -566,6 +566,31 @@ "submitSuccess": "Referral submitted for {name}.", "submitError": "Failed to submit referral." }, + "report": { + "createTitle": "Create Report", + "createButton": "Create Report", + "reportSubmitted": "Report submitted", + "testsUsedLabel": "Tests / assessments used", + "testsUsedPlaceholder": "e.g. ADOS-2, Vineland-3, WISC-V", + "diagnosisLabel": "Diagnosis & recommendation", + "diagnosisPlaceholder": "Describe the diagnosis and what the patient needs going forward.", + "validationError": "Please fill in both fields before submitting.", + "submit": "Submit report", + "submitSuccess": "Report created.", + "submitError": "Failed to create report.", + "myTitle": "Reports", + "patient": "Patient", + "therapist": "Therapist", + "submitted": "Submitted", + "status": "Status", + "delivered": "Delivered", + "pendingDelivery": "Pending delivery", + "deliver": "Deliver", + "deliverSuccess": "Report delivered.", + "deliverError": "Failed to deliver report.", + "empty": "No reports yet.", + "loadError": "Failed to load reports." + }, "sessionModal": { "session": "Session", "therapist": "Therapist", diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 8ea81b5..3db464b 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -566,6 +566,31 @@ "submitSuccess": "Informe enviado para {name}.", "submitError": "No se pudo enviar el informe." }, + "report": { + "createTitle": "Crear informe", + "createButton": "Crear informe", + "reportSubmitted": "Informe enviado", + "testsUsedLabel": "Pruebas / evaluaciones utilizadas", + "testsUsedPlaceholder": "p. ej. ADOS-2, Vineland-3, WISC-V", + "diagnosisLabel": "Diagnóstico y recomendación", + "diagnosisPlaceholder": "Describa el diagnóstico y lo que el paciente necesita a continuación.", + "validationError": "Complete ambos campos antes de enviar.", + "submit": "Enviar informe", + "submitSuccess": "Informe creado.", + "submitError": "No se pudo crear el informe.", + "myTitle": "Informes", + "patient": "Paciente", + "therapist": "Terapeuta", + "submitted": "Enviado", + "status": "Estado", + "delivered": "Entregado", + "pendingDelivery": "Pendiente de entrega", + "deliver": "Entregar", + "deliverSuccess": "Informe entregado.", + "deliverError": "No se pudo entregar el informe.", + "empty": "Aún no hay informes.", + "loadError": "No se pudieron cargar los informes." + }, "sessionModal": { "session": "Sesión", "therapist": "Terapeuta", diff --git a/pages/report/reportsView.vue b/pages/report/reportsView.vue new file mode 100644 index 0000000..29ff60f --- /dev/null +++ b/pages/report/reportsView.vue @@ -0,0 +1,158 @@ + + + + diff --git a/prisma/migrations/20260824003609_add_therapy_report/migration.sql b/prisma/migrations/20260824003609_add_therapy_report/migration.sql new file mode 100644 index 0000000..780d897 --- /dev/null +++ b/prisma/migrations/20260824003609_add_therapy_report/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "TherapyReport" ( + "id" TEXT NOT NULL PRIMARY KEY, + "patientId" TEXT NOT NULL, + "therapistId" TEXT NOT NULL, + "sessionId" TEXT, + "testsUsed" TEXT NOT NULL, + "diagnosis" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deliveredAt" DATETIME, + "deliveredById" TEXT, + CONSTRAINT "TherapyReport_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "TherapyReport_therapistId_fkey" FOREIGN KEY ("therapistId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "TherapyReport_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "TherapyReport_deliveredById_fkey" FOREIGN KEY ("deliveredById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 933cc6e..ae99636 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,6 +32,9 @@ model User { TherapistReferralsAsTherapist TherapistReferral[] @relation("TherapistReferrals") TherapistReferralsAsEvaluator TherapistReferral[] @relation("EvaluatorReferrals") loggedInRequests LoggedInRequest[] + + TherapyReportsAsTherapist TherapyReport[] @relation("TherapyReportsAsTherapist") + TherapyReportsDelivered TherapyReport[] @relation("TherapyReportsDelivered") } model NonEmployee { @@ -73,6 +76,7 @@ model Patient { TherapyNotes TherapyNote[] AppointmentRequests AppointmentRequest[] TherapistReferrals TherapistReferral[] + TherapyReports TherapyReport[] initialInterviewDate DateTime? programEvalDate DateTime? @@ -156,6 +160,7 @@ model Session { Patients SessionPatient[] AppointmentRequest AppointmentRequest? TherapyNotes TherapyNote[] + TherapyReports TherapyReport[] @@unique([time, therapistId]) } @@ -262,7 +267,7 @@ model TherapyNote { objectives TherapyNoteObjective[] sessionId String? - Session Session? @relation(fields: [sessionId], references: [id], onDelete: SetNull) + Session Session? @relation(fields: [sessionId], references: [id], onDelete: SetNull) } model TherapyNoteObjective { @@ -378,6 +383,29 @@ model TherapistReferral { Evaluator User @relation("EvaluatorReferrals", fields: [evaluatorId], references: [id], onDelete: Cascade) } +/// A therapist's post-appointment report for a patient: which tests/ +/// assessments were used and the resulting diagnosis/recommendation. Created +/// by the assigned therapist, then handed off by USER_SERVICE staff to the +/// patient (if an adult) or their guardian (if a minor) — tracked via +/// deliveredAt/deliveredById rather than a separate status enum, mirroring +/// SessionPatient.paid's boolean-ish "has this happened yet" shape. +model TherapyReport { + id String @id @default(uuid()) + patientId String + therapistId String + sessionId String? + testsUsed String + diagnosis String + createdAt DateTime @default(now()) + deliveredAt DateTime? + deliveredById String? + + Patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + Therapist User @relation("TherapyReportsAsTherapist", fields: [therapistId], references: [id], onDelete: Cascade) + Session Session? @relation(fields: [sessionId], references: [id], onDelete: SetNull) + DeliveredBy User? @relation("TherapyReportsDelivered", fields: [deliveredById], references: [id], onDelete: SetNull) +} + model Request { id Int @id @default(autoincrement()) firstName String diff --git a/server/api/session/[id]/reports.get.ts b/server/api/session/[id]/reports.get.ts new file mode 100644 index 0000000..629971d --- /dev/null +++ b/server/api/session/[id]/reports.get.ts @@ -0,0 +1,24 @@ +// server/api/session/[id]/reports.get.ts +import { AccessPermission } from "~/types/permissions"; + +export default defineAuthedHandler( + { + access: [AccessPermission.THERAPIST, AccessPermission.USER_SERVICE], + // PHI: only staff who may manage the session (USER_SERVICE / ADMIN) or + // the therapist who owns it can read its reports. + ownership: async (event) => { + const sessionId = getRouterParam(event, "id"); + if (!sessionId) return false; + return canManageSession(event, sessionId); + }, + }, + async (event) => { + const sessionId = getRouterParam(event, "id"); + if (!sessionId) return []; + + return await prisma.therapyReport.findMany({ + where: { sessionId }, + orderBy: { createdAt: "desc" }, + }); + } +); diff --git a/server/api/session/reports/[id]/deliver.post.ts b/server/api/session/reports/[id]/deliver.post.ts new file mode 100644 index 0000000..c8230ac --- /dev/null +++ b/server/api/session/reports/[id]/deliver.post.ts @@ -0,0 +1,87 @@ +import { AccessPermission } from "~/types/permissions"; + +export default defineAuthedHandler( + { access: [AccessPermission.USER_SERVICE, AccessPermission.ADMIN] }, + async (event) => { + const reportId = getRouterParam(event, "id"); + if (!reportId) { + throw createError({ + statusCode: 400, + statusMessage: "Missing report id.", + }); + } + + const report = await prisma.therapyReport.findUnique({ + where: { id: reportId }, + select: { id: true, patientId: true, deliveredAt: true }, + }); + if (!report) { + throw createError({ + statusCode: 404, + statusMessage: "Report not found.", + }); + } + if (report.deliveredAt) { + throw createError({ + statusCode: 400, + statusMessage: "Report already delivered.", + }); + } + + // Deliver to the primary guardian on file if the patient has one (a + // minor), otherwise to the patient directly (an adult, self-registered). + const guardianLink = await prisma.patientGuardian.findFirst({ + where: { patientId: report.patientId }, + orderBy: { primaryGuardian: "desc" }, + include: { Guardian: { include: { User: true } } }, + }); + + let recipientEmail: string; + let recipientName: string; + if (guardianLink) { + recipientEmail = guardianLink.Guardian.User.email; + recipientName = + guardianLink.Guardian.User.name ?? + guardianLink.Guardian.User.email; + } else { + const patient = await prisma.patient.findUnique({ + where: { id: report.patientId }, + include: { User: { include: { User: true } } }, + }); + if (!patient) { + throw createError({ + statusCode: 404, + statusMessage: "Patient not found.", + }); + } + recipientEmail = patient.User.User.email; + recipientName = patient.User.User.name ?? patient.User.User.email; + } + + try { + await sendEmail({ + to: recipientEmail, + subject: "New therapy report available", + text: `Hello ${recipientName},\n\nA new therapy report is ready for review. Please contact the clinic to schedule a time to go over it.\n\n— Connected Care`, + }); + } catch (err) { + console.error( + `Failed to send report-delivered email to ${recipientEmail}:`, + err + ); + throw createError({ + statusCode: 502, + statusMessage: + "Could not send the delivery notification email.", + }); + } + + return await prisma.therapyReport.update({ + where: { id: reportId }, + data: { + deliveredAt: new Date(), + deliveredById: event.context.user!.id, + }, + }); + } +); diff --git a/server/api/session/reports/index.get.ts b/server/api/session/reports/index.get.ts new file mode 100644 index 0000000..e53e9d0 --- /dev/null +++ b/server/api/session/reports/index.get.ts @@ -0,0 +1,26 @@ +import { AccessPermission } from "~/types/permissions"; + +export default defineAuthedHandler( + { + access: [ + AccessPermission.THERAPIST, + AccessPermission.USER_SERVICE, + AccessPermission.ADMIN, + ], + }, + async (event) => { + const p = event.context.permissions; + const user = event.context.user!; + + // A plain THERAPIST may only see reports they submitted; USER_SERVICE / + // ADMIN (coordinators) see all, since they're the ones delivering them. + const isTherapistOnly = + !!p[AccessPermission.THERAPIST] && + !(p[AccessPermission.USER_SERVICE] || p[AccessPermission.ADMIN]); + + return await prisma.therapyReport.findMany({ + where: isTherapistOnly ? { therapistId: user.id } : undefined, + orderBy: { createdAt: "desc" }, + }); + } +); diff --git a/server/api/session/reports/index.post.ts b/server/api/session/reports/index.post.ts new file mode 100644 index 0000000..c47eb61 --- /dev/null +++ b/server/api/session/reports/index.post.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { getMissingRequiredFields } from "~/composables/form/useRequestValidation"; +import { AccessPermission } from "~/types/permissions"; + +const therapyReportSchema = z.object({ + patientId: z.string().min(1), + sessionId: z.string().optional().nullable(), + testsUsed: z.string().min(1), + diagnosis: z.string().min(1), +}); + +export default defineAuthedHandler( + { + access: [AccessPermission.THERAPIST, AccessPermission.ADMIN], + ownership: async (event) => { + if (event.context.permissions[AccessPermission.ADMIN]) return true; + const data = await validateBody(event, therapyReportSchema); + return isAssignedTherapist(event, data.patientId); + }, + }, + async (event) => { + const data = await validateBody(event, therapyReportSchema); + + const missing = getMissingRequiredFields(data, [ + "patientId", + "testsUsed", + "diagnosis", + ]); + if (missing.length > 0) { + throw createError({ + statusCode: 400, + statusMessage: `Missing required fields: ${missing.join(", ")}`, + }); + } + + // Integrity: a report may only be attached to a session the patient + // actually attends, same guard as therapy notes. + if (data.sessionId) { + const onRoster = await prisma.sessionPatient.findUnique({ + where: { + sessionId_patientId: { + sessionId: data.sessionId, + patientId: data.patientId, + }, + }, + select: { sessionId: true }, + }); + if (!onRoster) { + throw createError({ + statusCode: 400, + statusMessage: "Session does not include this patient.", + }); + } + } + + // The submitting therapist is always the authenticated user, never a + // client-supplied id. + const therapistId = event.context.user!.id; + + try { + return await prisma.therapyReport.create({ + data: { + patientId: data.patientId, + sessionId: data.sessionId || null, + testsUsed: data.testsUsed, + diagnosis: data.diagnosis, + therapistId, + }, + }); + } catch (e) { + handlePrismaError(e); + } + } +); diff --git a/types/permissions.ts b/types/permissions.ts index 537f422..15e4622 100644 --- a/types/permissions.ts +++ b/types/permissions.ts @@ -57,6 +57,7 @@ const pageAccessMap: { [routeName: string]: AccessPermission } = { "intake-id": AccessPermission.USER_SERVICE, "request-id": AccessPermission.USER_SERVICE, "session-sessionsView": AccessPermission.USER_SERVICE, + "report-reportsView": AccessPermission.USER_SERVICE, // IT Service Pages "dashboard-iTServiceDashboard": AccessPermission.IT_SERVICE, // Admin Pages