evaldoc 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.env.staging.example +19 -0
  2. package/.firebaserc +6 -0
  3. package/.github/workflows/auto-merge-to-main.yml +28 -0
  4. package/.github/workflows/ci.yml +27 -0
  5. package/.github/workflows/npxhub-publish.yml +170 -0
  6. package/.prettierignore +4 -0
  7. package/.prettierrc.json +7 -0
  8. package/README.md +142 -0
  9. package/SRS.md +448 -0
  10. package/apphosting.staging.yaml +48 -0
  11. package/apphosting.yaml +50 -0
  12. package/bin/evaldoc.cjs +105 -0
  13. package/cors.json +8 -0
  14. package/docs/ARCHITECTURE.md +769 -0
  15. package/docs/ARCHITECTURE_SUMMARY.md +323 -0
  16. package/docs/DEPLOYMENT.md +86 -0
  17. package/docs/DEPLOYMENT_STEPS.md +373 -0
  18. package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
  19. package/docs/DEPLOY_STORAGE_RULES.md +82 -0
  20. package/docs/DOCTOR_TUTORIAL.md +467 -0
  21. package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
  22. package/docs/FIXES_APPLIED.md +128 -0
  23. package/docs/IMPLEMENTATION.md +498 -0
  24. package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
  25. package/docs/LLM-SETUP.md +216 -0
  26. package/docs/MANUAL_TEST_GUIDE.md +327 -0
  27. package/docs/PATIENT_GUIDE.md +451 -0
  28. package/docs/PLATFORM_OVERVIEW.md +194 -0
  29. package/docs/QUICKSTART.md +252 -0
  30. package/docs/QUICK_REFERENCE.md +222 -0
  31. package/docs/READY_TO_TEST.md +383 -0
  32. package/docs/RUN.md +237 -0
  33. package/docs/START_TESTING.md +130 -0
  34. package/docs/STRIPE_CONNECT_SETUP.md +454 -0
  35. package/docs/SYSTEM_READY.md +345 -0
  36. package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
  37. package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
  38. package/docs/firebasefiles.md +21 -0
  39. package/docs/homepage.md +1208 -0
  40. package/docs/payments.md +159 -0
  41. package/eslint.config.js +76 -0
  42. package/firebase.json +27 -0
  43. package/firestore.indexes.json +88 -0
  44. package/firestore.rules +125 -0
  45. package/functions/package-lock.json +2884 -0
  46. package/functions/package.json +20 -0
  47. package/functions/src/analytics.js +101 -0
  48. package/functions/src/audit.js +28 -0
  49. package/functions/src/auth.js +19 -0
  50. package/functions/src/drafts.js +128 -0
  51. package/functions/src/feedback.js +164 -0
  52. package/functions/src/firebase-init.js +29 -0
  53. package/functions/src/i18n.js +134 -0
  54. package/functions/src/limits.js +27 -0
  55. package/functions/src/llm.js +271 -0
  56. package/functions/src/ner.js +162 -0
  57. package/functions/src/notifications.js +273 -0
  58. package/functions/src/orgs.js +127 -0
  59. package/functions/src/patient-api.js +545 -0
  60. package/functions/src/payment.js +830 -0
  61. package/functions/src/safety.js +188 -0
  62. package/functions/src/stripe-config.js +33 -0
  63. package/functions/src/topics.js +94 -0
  64. package/functions/src/triage.js +191 -0
  65. package/functions/src/utils.js +28 -0
  66. package/functions/src/validate.js +177 -0
  67. package/functions/test-firebase.js +36 -0
  68. package/jest.config.js +18 -0
  69. package/package.json +52 -0
  70. package/public/404.html +93 -0
  71. package/public/app/analytics.html +336 -0
  72. package/public/app/analytics.js +177 -0
  73. package/public/app/app.css +1400 -0
  74. package/public/app/app.js +1754 -0
  75. package/public/app/config.js +14 -0
  76. package/public/app/inbox-threads.js +843 -0
  77. package/public/app/inbox.html +598 -0
  78. package/public/app/inbox.js +397 -0
  79. package/public/app/index.html +612 -0
  80. package/public/assets/logo.jpg +0 -0
  81. package/public/chat.html +137 -0
  82. package/public/css/chat.css +870 -0
  83. package/public/css/landing-enhance.css +298 -0
  84. package/public/css/styles.css +1198 -0
  85. package/public/favicon.svg +5 -0
  86. package/public/find-doctor.html +220 -0
  87. package/public/index.html +399 -0
  88. package/public/js/chat-threads.js +513 -0
  89. package/public/js/chat.js +832 -0
  90. package/public/js/search.js +41 -0
  91. package/public/privacy.html +101 -0
  92. package/public/robots.txt +6 -0
  93. package/public/site.webmanifest +12 -0
  94. package/public/terms.html +110 -0
  95. package/scripts/create-test-provider.js +55 -0
  96. package/scripts/deploy-staging.sh +51 -0
  97. package/scripts/fix-provider.js +39 -0
  98. package/scripts/quick-test.sh +37 -0
  99. package/scripts/test-chat.sh +54 -0
  100. package/scripts/test-multi-turn.js +137 -0
  101. package/scripts/test-ner-simple.js +28 -0
  102. package/scripts/test-ner.js +60 -0
  103. package/scripts/test-stripe-connect.js +304 -0
  104. package/scripts/test-webhook-secret.sh +24 -0
  105. package/server.js +3864 -0
  106. package/storage.rules +22 -0
  107. package/test-api.json +1 -0
  108. package/tests/emulator/README.md +38 -0
  109. package/tests/emulator/counters.emulator.test.js +81 -0
  110. package/tests/integration/api-account-selfserve.test.js +312 -0
  111. package/tests/integration/api-admin-digest.test.js +202 -0
  112. package/tests/integration/api-chat-satisfaction.test.js +259 -0
  113. package/tests/integration/api-doctor-drafts.test.js +236 -0
  114. package/tests/integration/api-doctor-inbox.test.js +401 -0
  115. package/tests/integration/api-org.test.js +328 -0
  116. package/tests/integration/api-payment.test.js +664 -0
  117. package/tests/integration/api-provider-settings.test.js +475 -0
  118. package/tests/integration/api-qr.test.js +121 -0
  119. package/tests/integration/api-security.test.js +241 -0
  120. package/tests/integration/api-stripe-search.test.js +303 -0
  121. package/tests/integration/api-webhook-deletion.test.js +231 -0
  122. package/tests/mocks/firebase-admin.js +220 -0
  123. package/tests/unit/analytics.test.js +151 -0
  124. package/tests/unit/audit-bugs.test.js +203 -0
  125. package/tests/unit/chat-ui-logic.test.js +283 -0
  126. package/tests/unit/drafts.test.js +104 -0
  127. package/tests/unit/feedback.test.js +148 -0
  128. package/tests/unit/i18n.test.js +229 -0
  129. package/tests/unit/llm-retry.test.js +122 -0
  130. package/tests/unit/notifications.test.js +155 -0
  131. package/tests/unit/orgs.test.js +328 -0
  132. package/tests/unit/payment.test.js +889 -0
  133. package/tests/unit/safety-edge-cases.test.js +509 -0
  134. package/tests/unit/safety-srs-compliance.test.js +252 -0
  135. package/tests/unit/safety.test.js +291 -0
  136. package/tests/unit/server-helpers.test.js +774 -0
  137. package/tests/unit/topics-limits.test.js +158 -0
  138. package/tests/unit/triage.test.js +152 -0
  139. package/tests/unit/validate.test.js +408 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Organizations & RBAC — pure logic core for multi-provider clinics.
3
+ *
4
+ * This module is intentionally side-effect-free so authorization decisions and
5
+ * seat math are exhaustively unit-testable. Firestore reads/writes live in the
6
+ * server endpoints; this file only answers "is this allowed?" and "how many
7
+ * seats?" questions.
8
+ *
9
+ * Roles (highest → lowest authority):
10
+ * owner — billing, delete org, manage everyone, full access
11
+ * admin — manage members (not owner), org settings, view all inboxes
12
+ * clinician — own provider profile + own inbox, reply to patients
13
+ * staff — triage/reply in assigned inboxes only (no clinical settings)
14
+ */
15
+
16
+ const ROLES = ['owner', 'admin', 'clinician', 'staff'];
17
+
18
+ // Higher number = more authority. Used for "can actor manage target?" checks.
19
+ const ROLE_RANK = {
20
+ owner: 4,
21
+ admin: 3,
22
+ clinician: 2,
23
+ staff: 1
24
+ };
25
+
26
+ // action -> roles allowed to perform it
27
+ const PERMISSIONS = {
28
+ 'billing.manage': ['owner'],
29
+ 'org.delete': ['owner'],
30
+ 'members.invite': ['owner', 'admin'],
31
+ 'members.remove': ['owner', 'admin'],
32
+ 'members.role': ['owner', 'admin'],
33
+ 'inbox.viewAll': ['owner', 'admin'],
34
+ 'settings.edit': ['owner', 'admin'],
35
+ 'provider.own': ['owner', 'admin', 'clinician'],
36
+ 'inbox.viewOwn': ['owner', 'admin', 'clinician', 'staff'],
37
+ 'inbox.reply': ['owner', 'admin', 'clinician', 'staff']
38
+ };
39
+
40
+ // Seat caps per plan. null = unlimited.
41
+ const SEAT_LIMITS = {
42
+ free: 1,
43
+ pro: 1,
44
+ clinic: 5,
45
+ enterprise: null
46
+ };
47
+
48
+ /** Is `role` allowed to perform `action`? */
49
+ function can(role, action) {
50
+ const allowed = PERMISSIONS[action];
51
+ if (!allowed) return false;
52
+ return allowed.includes(role);
53
+ }
54
+
55
+ /** Is `role` a recognized role? */
56
+ function isValidRole(role) {
57
+ return ROLES.includes(role);
58
+ }
59
+
60
+ /**
61
+ * Can an actor with `actorRole` manage (change role / remove) a member with
62
+ * `targetRole`? Only owner/admin can manage at all; an actor can never manage
63
+ * someone of equal-or-higher authority (so admins can't touch owners or each
64
+ * other, and owners can manage anyone below them).
65
+ */
66
+ function canManageMember(actorRole, targetRole) {
67
+ if (!isValidRole(actorRole) || !isValidRole(targetRole)) return false;
68
+ if (actorRole !== 'owner' && actorRole !== 'admin') return false;
69
+ return ROLE_RANK[actorRole] > ROLE_RANK[targetRole];
70
+ }
71
+
72
+ /** Highest role an actor is allowed to assign when inviting/promoting. */
73
+ function canAssignRole(actorRole, newRole) {
74
+ if (!isValidRole(actorRole) || !isValidRole(newRole)) return false;
75
+ if (actorRole !== 'owner' && actorRole !== 'admin') return false;
76
+ // Nobody can mint another owner via invite/role-change; ownership transfers
77
+ // are a separate, explicit flow. Admins can't create admins.
78
+ if (newRole === 'owner') return false;
79
+ if (actorRole === 'admin' && newRole === 'admin') return false;
80
+ return ROLE_RANK[actorRole] >= ROLE_RANK[newRole];
81
+ }
82
+
83
+ /** Seat cap for a plan (null = unlimited). */
84
+ function seatLimitForPlan(plan) {
85
+ return Object.prototype.hasOwnProperty.call(SEAT_LIMITS, plan) ? SEAT_LIMITS[plan] : 1;
86
+ }
87
+
88
+ /** Remaining seats given a plan and current active member count. */
89
+ function seatsRemaining(plan, activeMemberCount) {
90
+ const limit = seatLimitForPlan(plan);
91
+ if (limit === null) return Infinity;
92
+ return Math.max(0, limit - activeMemberCount);
93
+ }
94
+
95
+ /** Is there room for one more member? */
96
+ function hasSeatAvailable(plan, activeMemberCount) {
97
+ return seatsRemaining(plan, activeMemberCount) > 0;
98
+ }
99
+
100
+ /** Default org shape for a brand-new personal org (one owner, free plan). */
101
+ function personalOrgDefaults({ orgId, ownerUid, name }) {
102
+ return {
103
+ id: orgId,
104
+ ownerUid,
105
+ name: name || 'My Practice',
106
+ type: 'personal',
107
+ plan: 'free',
108
+ seats: 1,
109
+ createdAt: null, // stamped by caller with serverTimestamp
110
+ updatedAt: null
111
+ };
112
+ }
113
+
114
+ module.exports = {
115
+ ROLES,
116
+ ROLE_RANK,
117
+ PERMISSIONS,
118
+ SEAT_LIMITS,
119
+ can,
120
+ isValidRole,
121
+ canManageMember,
122
+ canAssignRole,
123
+ seatLimitForPlan,
124
+ seatsRemaining,
125
+ hasSeatAvailable,
126
+ personalOrgDefaults
127
+ };
@@ -0,0 +1,545 @@
1
+ const admin = require("firebase-admin");
2
+ const { jsonResponse, readBody, nowTimestamp } = require("./utils");
3
+ const { generateResponse } = require("./llm");
4
+ const { analyzeInput, analyzeOutput, escalationMessage } = require("./safety");
5
+ const { TOPICS, TOPIC_KEYS } = require("./topics");
6
+
7
+ // Phase 2 imports
8
+ const { classifyConversation } = require("./triage");
9
+ const { generateDoctorDraft } = require("./drafts");
10
+ const { notifyEscalation, notifyDraftReady } = require("./notifications");
11
+ const { retrieveRelevantExamples } = require("./feedback");
12
+ const { checkPaymentAccess } = require("./payment");
13
+
14
+ /**
15
+ * Get or create patient profile
16
+ */
17
+ async function getPatient(uid) {
18
+ const db = admin.firestore();
19
+ const patientDoc = await db.collection("patients").doc(uid).get();
20
+
21
+ if (!patientDoc.exists) {
22
+ return null;
23
+ }
24
+
25
+ return { id: patientDoc.id, ...patientDoc.data() };
26
+ }
27
+
28
+ /**
29
+ * Create patient profile
30
+ */
31
+ async function createPatient(uid, data) {
32
+ const db = admin.firestore();
33
+ const patientRef = db.collection("patients").doc(uid);
34
+
35
+ const patientData = {
36
+ email: data.email,
37
+ displayName: data.displayName || null,
38
+ photoURL: data.photoURL || null,
39
+ doctorIds: [],
40
+ notificationsEnabled: true,
41
+ fcmToken: null,
42
+ createdAt: nowTimestamp(admin),
43
+ lastActiveAt: nowTimestamp(admin)
44
+ };
45
+
46
+ await patientRef.set(patientData);
47
+
48
+ return { id: uid, ...patientData };
49
+ }
50
+
51
+ /**
52
+ * Find doctor by handle
53
+ */
54
+ async function findDoctorByHandle(handle) {
55
+ const db = admin.firestore();
56
+ const snapshot = await db
57
+ .collection("providers")
58
+ .where("handle", "==", handle)
59
+ .where("status", "==", "published")
60
+ .limit(1)
61
+ .get();
62
+
63
+ if (snapshot.empty) return null;
64
+ return { id: snapshot.docs[0].id, ...snapshot.docs[0].data() };
65
+ }
66
+
67
+ /**
68
+ * Get thread ID from patient and doctor IDs
69
+ */
70
+ function getThreadId(patientId, doctorId) {
71
+ return `thread_${patientId}_${doctorId}`;
72
+ }
73
+
74
+ /**
75
+ * Get or create thread between patient and doctor
76
+ */
77
+ async function getOrCreateThread(patientId, doctorId, patientData, doctorData) {
78
+ const db = admin.firestore();
79
+ const threadId = getThreadId(patientId, doctorId);
80
+ const threadRef = db.collection("threads").doc(threadId);
81
+ const threadDoc = await threadRef.get();
82
+
83
+ if (threadDoc.exists) {
84
+ return { id: threadDoc.id, ...threadDoc.data() };
85
+ }
86
+
87
+ // Create new thread with Phase 2 fields
88
+ const threadData = {
89
+ patientId,
90
+ patientEmail: patientData.email,
91
+ patientName: patientData.displayName || null,
92
+ patientPhoto: patientData.photoURL || null,
93
+ patientSource: "authenticated",
94
+
95
+ doctorId,
96
+ doctorName: doctorData.displayName,
97
+ doctorHandle: doctorData.handle,
98
+
99
+ status: "active",
100
+ isEscalated: false,
101
+ doctorOverride: false,
102
+
103
+ lastMessageAt: nowTimestamp(admin),
104
+ lastMessageText: null,
105
+ lastMessageRole: null,
106
+
107
+ unreadByPatient: 0,
108
+ unreadByDoctor: 0,
109
+ messageCount: 0,
110
+
111
+ currentTopic: null,
112
+
113
+ isPaid: false,
114
+ paymentId: null,
115
+
116
+ // Phase 2 fields
117
+ triageCategory: null,
118
+ triageConfidence: null,
119
+ triageReason: null,
120
+ triageUpdatedAt: null,
121
+
122
+ aiDraftResponse: null,
123
+ aiDraftGeneratedAt: null,
124
+ aiDraftStatus: null,
125
+
126
+ lastDoctorNotifiedAt: null,
127
+ doctorResponseFedBack: false,
128
+ feedbackProcessedAt: null,
129
+
130
+ createdAt: nowTimestamp(admin),
131
+ updatedAt: nowTimestamp(admin)
132
+ };
133
+
134
+ await threadRef.set(threadData);
135
+
136
+ // Add doctor to patient's doctorIds
137
+ await db.collection("patients").doc(patientId).update({
138
+ doctorIds: admin.firestore.FieldValue.arrayUnion(doctorId),
139
+ lastActiveAt: nowTimestamp(admin)
140
+ });
141
+
142
+ return { id: threadId, ...threadData };
143
+ }
144
+
145
+ /**
146
+ * Add message to thread
147
+ */
148
+ async function addMessageToThread(threadId, role, message, metadata = {}) {
149
+ const db = admin.firestore();
150
+ const threadRef = db.collection("threads").doc(threadId);
151
+ const messagesRef = threadRef.collection("messages");
152
+
153
+ const messageData = {
154
+ threadId,
155
+ role, // patient, doctor, ai
156
+ message,
157
+ content: message, // dual-field for compatibility
158
+
159
+ isDoctorManual: metadata.isDoctorManual || false,
160
+ doctorUserId: metadata.doctorUserId || null,
161
+
162
+ readByPatient: role === "patient",
163
+ readByDoctor: false,
164
+
165
+ wasFiltered: metadata.wasFiltered || false,
166
+ isAiGenerated: metadata.isAiGenerated || false,
167
+
168
+ createdAt: nowTimestamp(admin)
169
+ };
170
+
171
+ const messageRef = await messagesRef.add(messageData);
172
+
173
+ // Update thread metadata
174
+ const updateData = {
175
+ lastMessageAt: nowTimestamp(admin),
176
+ lastMessageText: message.substring(0, 100),
177
+ lastMessageRole: role,
178
+ messageCount: admin.firestore.FieldValue.increment(1),
179
+ updatedAt: nowTimestamp(admin)
180
+ };
181
+
182
+ if (role === "patient") {
183
+ updateData.unreadByDoctor = admin.firestore.FieldValue.increment(1);
184
+ } else {
185
+ updateData.unreadByPatient = admin.firestore.FieldValue.increment(1);
186
+ }
187
+
188
+ await threadRef.update(updateData);
189
+
190
+ return { id: messageRef.id, ...messageData };
191
+ }
192
+
193
+ /**
194
+ * Get messages from thread
195
+ */
196
+ async function getThreadMessages(threadId, limit = 50) {
197
+ const db = admin.firestore();
198
+ const messagesSnapshot = await db
199
+ .collection("threads")
200
+ .doc(threadId)
201
+ .collection("messages")
202
+ .orderBy("createdAt", "asc")
203
+ .limit(limit)
204
+ .get();
205
+
206
+ return messagesSnapshot.docs.map(doc => ({
207
+ id: doc.id,
208
+ ...doc.data()
209
+ }));
210
+ }
211
+
212
+ /**
213
+ * Mark messages as read
214
+ */
215
+ async function markThreadAsRead(threadId, role) {
216
+ const db = admin.firestore();
217
+ const threadRef = db.collection("threads").doc(threadId);
218
+
219
+ if (role === "patient") {
220
+ await threadRef.update({
221
+ unreadByPatient: 0
222
+ });
223
+ } else if (role === "doctor") {
224
+ await threadRef.update({
225
+ unreadByDoctor: 0
226
+ });
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Handle patient API routes
232
+ */
233
+ async function handlePatientAPI(req, res, user) {
234
+ const db = admin.firestore();
235
+
236
+ // GET /api/patient/me - Get patient profile
237
+ if (req.method === "GET" && (req.path === "/patient/me" || req.path === "/me")) {
238
+ const patient = await getPatient(user.uid);
239
+ if (!patient) {
240
+ jsonResponse(res, 404, { error: "Patient not found" });
241
+ return;
242
+ }
243
+ jsonResponse(res, 200, patient);
244
+ return;
245
+ }
246
+
247
+ // POST /api/patient/create - Create patient profile
248
+ if (req.method === "POST" && req.path === "/patient/create") {
249
+ const body = readBody(req) || {};
250
+ const existing = await getPatient(user.uid);
251
+
252
+ if (existing) {
253
+ jsonResponse(res, 200, existing);
254
+ return;
255
+ }
256
+
257
+ const patient = await createPatient(user.uid, {
258
+ email: user.email || body.email,
259
+ displayName: body.displayName || user.name,
260
+ photoURL: body.photoURL
261
+ });
262
+
263
+ jsonResponse(res, 200, patient);
264
+ return;
265
+ }
266
+
267
+ // POST /api/patient/thread - Get or create thread with doctor
268
+ if (req.method === "POST" && req.path === "/patient/thread") {
269
+ const body = readBody(req) || {};
270
+ const doctorHandle = body.doctorHandle;
271
+
272
+ if (!doctorHandle) {
273
+ jsonResponse(res, 400, { error: "doctorHandle is required" });
274
+ return;
275
+ }
276
+
277
+ const patient = await getPatient(user.uid);
278
+ if (!patient) {
279
+ jsonResponse(res, 404, { error: "Patient profile not found" });
280
+ return;
281
+ }
282
+
283
+ const doctor = await findDoctorByHandle(doctorHandle);
284
+ if (!doctor) {
285
+ jsonResponse(res, 404, { error: "Doctor not found" });
286
+ return;
287
+ }
288
+
289
+ const thread = await getOrCreateThread(user.uid, doctor.id, patient, doctor);
290
+
291
+ jsonResponse(res, 200, { thread });
292
+ return;
293
+ }
294
+
295
+ // GET /api/patient/threads - Get all patient threads
296
+ if (req.method === "GET" && req.path === "/patient/threads") {
297
+ const threadsSnapshot = await db
298
+ .collection("threads")
299
+ .where("patientId", "==", user.uid)
300
+ .orderBy("lastMessageAt", "desc")
301
+ .limit(50)
302
+ .get();
303
+
304
+ const threads = threadsSnapshot.docs.map(doc => ({
305
+ id: doc.id,
306
+ ...doc.data()
307
+ }));
308
+
309
+ jsonResponse(res, 200, { threads });
310
+ return;
311
+ }
312
+
313
+ // GET /api/patient/thread/:threadId/messages - Get messages
314
+ if (req.method === "GET" && req.path.match(/^\/patient\/thread\/[^\/]+\/messages$/)) {
315
+ const threadId = req.path.split("/")[3];
316
+
317
+ // Verify patient owns this thread
318
+ const threadDoc = await db.collection("threads").doc(threadId).get();
319
+ if (!threadDoc.exists || threadDoc.data().patientId !== user.uid) {
320
+ jsonResponse(res, 404, { error: "Thread not found" });
321
+ return;
322
+ }
323
+
324
+ const messages = await getThreadMessages(threadId);
325
+ await markThreadAsRead(threadId, "patient");
326
+
327
+ jsonResponse(res, 200, { messages });
328
+ return;
329
+ }
330
+
331
+ // POST /api/patient/message - Send message to doctor
332
+ if (req.method === "POST" && req.path === "/patient/message") {
333
+ const body = readBody(req) || {};
334
+ const { threadId, message, topic } = body;
335
+
336
+ if (!threadId || !message) {
337
+ jsonResponse(res, 400, { error: "threadId and message are required" });
338
+ return;
339
+ }
340
+
341
+ // Verify patient owns this thread
342
+ const threadDoc = await db.collection("threads").doc(threadId).get();
343
+ if (!threadDoc.exists || threadDoc.data().patientId !== user.uid) {
344
+ jsonResponse(res, 404, { error: "Thread not found" });
345
+ return;
346
+ }
347
+
348
+ const thread = threadDoc.data();
349
+ const doctorDoc = await db.collection("providers").doc(thread.doctorId).get();
350
+ const doctor = doctorDoc.data();
351
+
352
+ // Payment access check
353
+ if (doctor.paymentEnabled && doctor.pricingModel !== 'free') {
354
+ const accessResult = await checkPaymentAccess({
355
+ providerId: thread.doctorId,
356
+ patientId: user.uid
357
+ });
358
+
359
+ if (!accessResult.hasAccess) {
360
+ jsonResponse(res, 402, {
361
+ error: "Payment required",
362
+ paymentRequired: true,
363
+ paymentConfig: {
364
+ pricingModel: doctor.pricingModel,
365
+ subscriptionPrice: doctor.subscriptionPrice || 0,
366
+ perChatPrice: doctor.perChatPrice || 0,
367
+ currency: doctor.currency || 'usd',
368
+ handle: doctor.handle,
369
+ stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY
370
+ }
371
+ });
372
+ return;
373
+ }
374
+ }
375
+
376
+ // Safety check on input
377
+ const topicKey = topic || thread.currentTopic || "general_health_info";
378
+ const topicConfigForSafety = TOPICS[topicKey] || TOPICS[TOPIC_KEYS[0]];
379
+ const inputSafety = await analyzeInput(message, topicConfigForSafety);
380
+ if (inputSafety.blocked) {
381
+ jsonResponse(res, 400, { error: "Message blocked by safety filter" });
382
+ return;
383
+ }
384
+
385
+ // Save patient message
386
+ await addMessageToThread(threadId, "patient", message);
387
+
388
+ // Update thread topic if provided
389
+ if (topic) {
390
+ await threadDoc.ref.update({ currentTopic: topic });
391
+ }
392
+
393
+ // Generate AI response (unless doctor has override enabled)
394
+ let aiResponse = "Thank you for your message. The doctor will respond soon.";
395
+ let isEscalated = false;
396
+
397
+ if (!thread.doctorOverride) {
398
+ const threadMessages = await getThreadMessages(threadId, 10);
399
+ const conversationHistory = threadMessages.map(msg => ({
400
+ role: msg.role === "patient" ? "user" : "assistant",
401
+ content: msg.message
402
+ }));
403
+
404
+ const aiTopicKey = topic || thread.currentTopic || "general_health_info";
405
+ const topicConfig = TOPICS[aiTopicKey] || TOPICS[TOPIC_KEYS[0]];
406
+
407
+ // Retrieve doctor examples for personalized responses (Component 5)
408
+ let doctorExamples = [];
409
+ try {
410
+ doctorExamples = await retrieveRelevantExamples({
411
+ doctorId: thread.doctorId,
412
+ topic: topicKey,
413
+ limit: 3
414
+ });
415
+ } catch (err) {
416
+ console.error("[Patient API] Failed to retrieve doctor examples:", err.message);
417
+ }
418
+
419
+ aiResponse = await generateResponse({
420
+ topicConfig,
421
+ message,
422
+ provider: doctor,
423
+ conversationHistory,
424
+ doctorExamples
425
+ });
426
+
427
+ // Safety check on output
428
+ const outputSafety = analyzeOutput(aiResponse, topicConfig);
429
+ if (outputSafety.blocked) {
430
+ aiResponse = "I can't help with that. For medical advice, please contact a licensed clinician.";
431
+ }
432
+ if (inputSafety.escalated) {
433
+ isEscalated = true;
434
+ await threadDoc.ref.update({ isEscalated: true });
435
+ }
436
+ }
437
+
438
+ // Save AI response
439
+ await addMessageToThread(threadId, "ai", aiResponse, { isAiGenerated: true });
440
+
441
+ // Fire-and-forget: Triage classification + draft generation + notifications (Phase 2)
442
+ const allMessages = [
443
+ ...(await getThreadMessages(threadId, 10)).map(m => ({
444
+ role: m.role === "patient" ? "user" : "assistant",
445
+ content: m.message || m.content
446
+ }))
447
+ ];
448
+
449
+ classifyConversation({ messages: allMessages, isEscalated })
450
+ .then(async (triage) => {
451
+ console.log(`[Triage] Thread ${threadId}: ${triage.category} (${triage.confidence})`);
452
+ const triageUpdate = {
453
+ triageCategory: triage.category,
454
+ triageConfidence: triage.confidence,
455
+ triageReason: triage.reason,
456
+ triageUpdatedAt: admin.firestore.FieldValue.serverTimestamp()
457
+ };
458
+ await threadDoc.ref.update(triageUpdate);
459
+
460
+ // Generate draft for urgent/needs_review (Component 3)
461
+ if (triage.category === "urgent" || triage.category === "needs_review") {
462
+ try {
463
+ let draftExamples = [];
464
+ try {
465
+ draftExamples = await retrieveRelevantExamples({
466
+ doctorId: thread.doctorId,
467
+ topic: topic || thread.currentTopic,
468
+ limit: 3
469
+ });
470
+ } catch (e) { /* ignore */ }
471
+
472
+ const draftTopicKey = topic || thread.currentTopic || "general_health_info";
473
+ const draftResult = await generateDoctorDraft({
474
+ messages: allMessages,
475
+ provider: doctor,
476
+ topic: draftTopicKey,
477
+ triageCategory: triage.category,
478
+ doctorExamples: draftExamples
479
+ });
480
+
481
+ if (draftResult) {
482
+ await threadDoc.ref.update({
483
+ aiDraftResponse: draftResult.draft,
484
+ aiDraftGeneratedAt: admin.firestore.FieldValue.serverTimestamp(),
485
+ aiDraftStatus: "pending"
486
+ });
487
+
488
+ notifyDraftReady({
489
+ doctorId: thread.doctorId,
490
+ threadId,
491
+ patientName: thread.patientName
492
+ }).catch(err => console.error("[Notifications] Draft notify failed:", err.message));
493
+ }
494
+ } catch (draftErr) {
495
+ console.error("[Drafts] Generation failed:", draftErr.message);
496
+ }
497
+
498
+ // Notify escalation (Component 4)
499
+ if (triage.category === "urgent") {
500
+ notifyEscalation({
501
+ doctorId: thread.doctorId,
502
+ threadId,
503
+ patientName: thread.patientName,
504
+ reason: triage.reason
505
+ }).catch(err => console.error("[Notifications] Escalation notify failed:", err.message));
506
+ }
507
+ }
508
+ })
509
+ .catch(err => console.error("[Triage] Classification failed:", err.message));
510
+
511
+ jsonResponse(res, 200, {
512
+ response: aiResponse,
513
+ escalation: isEscalated
514
+ });
515
+ return;
516
+ }
517
+
518
+ // POST /api/patient/thread/:threadId/read - Mark as read
519
+ if (req.method === "POST" && req.path.match(/^\/patient\/thread\/[^\/]+\/read$/)) {
520
+ const threadId = req.path.split("/")[3];
521
+
522
+ // Verify patient owns this thread
523
+ const threadDoc = await db.collection("threads").doc(threadId).get();
524
+ if (!threadDoc.exists || threadDoc.data().patientId !== user.uid) {
525
+ jsonResponse(res, 404, { error: "Thread not found" });
526
+ return;
527
+ }
528
+
529
+ await markThreadAsRead(threadId, "patient");
530
+ jsonResponse(res, 200, { success: true });
531
+ return;
532
+ }
533
+
534
+ jsonResponse(res, 404, { error: "Not found" });
535
+ }
536
+
537
+ module.exports = {
538
+ handlePatientAPI,
539
+ getPatient,
540
+ createPatient,
541
+ getOrCreateThread,
542
+ addMessageToThread,
543
+ getThreadMessages,
544
+ markThreadAsRead
545
+ };