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,401 @@
1
+ /**
2
+ * Integration tests for the doctor inbox API.
3
+ *
4
+ * THIS is the test that would have caught the "conversations not showing" bug.
5
+ * It verifies that a doctor's inbox returns data from BOTH the threads collection
6
+ * AND the legacy conversations collection.
7
+ */
8
+
9
+ process.env.NODE_ENV = 'test';
10
+
11
+ jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
12
+ jest.mock('stripe', () => jest.fn().mockReturnValue({}));
13
+
14
+ // Mock LLM and Stripe to prevent import errors
15
+ jest.mock('openai', () => jest.fn().mockImplementation(() => ({
16
+ chat: { completions: { create: jest.fn().mockResolvedValue({ choices: [{ message: { content: 'mock' } }] }) } }
17
+ })));
18
+ jest.mock('@google/generative-ai', () => ({
19
+ GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
20
+ getGenerativeModel: () => ({
21
+ generateContent: jest.fn().mockResolvedValue({ response: { text: () => 'mock' } })
22
+ })
23
+ }))
24
+ }));
25
+ jest.mock('../../functions/src/stripe-config', () => ({ stripe: {} }));
26
+ jest.mock('../../functions/src/payment', () => ({
27
+ createConnectAccount: jest.fn(),
28
+ createAccountLink: jest.fn(),
29
+ refreshAccountStatus: jest.fn(),
30
+ setProviderPricing: jest.fn(),
31
+ createCheckoutSession: jest.fn(),
32
+ createPaymentIntent: jest.fn(),
33
+ createSubscription: jest.fn(),
34
+ cancelSubscription: jest.fn(),
35
+ checkPaymentAccess: jest.fn(),
36
+ getRevenueAnalytics: jest.fn(),
37
+ handleStripeWebhook: jest.fn()
38
+ }));
39
+
40
+ process.env.DEEPSEEK_API_KEY = 'test-key';
41
+ process.env.GEMINI_API_KEY = 'test-key';
42
+
43
+ const request = require('supertest');
44
+ const admin = require('firebase-admin');
45
+
46
+ // Override auth mock to accept our test token
47
+ admin.auth().verifyIdToken = jest.fn(async (token) => {
48
+ if (token === 'valid-doctor-token') return { uid: 'doctor-uid-1', email: 'doc@test.com' };
49
+ throw new Error('Invalid token');
50
+ });
51
+
52
+ const app = require('../../server');
53
+
54
+ describe('Doctor Inbox API', () => {
55
+ beforeEach(() => {
56
+ admin.__reset();
57
+
58
+ // Seed the doctor's provider record
59
+ admin.__seed('providers', 'doctor-uid-1', {
60
+ ownerUid: 'doctor-uid-1',
61
+ handle: 'drsmith',
62
+ displayName: 'Dr. Smith',
63
+ specialty: 'General',
64
+ status: 'published',
65
+ enabledTopics: ['general_health_info'],
66
+ plan: 'free',
67
+ monthlyChatLimit: 50,
68
+ monthlyChatCount: 0
69
+ });
70
+ });
71
+
72
+ describe('GET /api/doctor/threads', () => {
73
+ it('should return threads from the threads collection', async () => {
74
+ admin.__seed('threads', 'thread-1', {
75
+ doctorId: 'doctor-uid-1',
76
+ patientName: 'Alice',
77
+ lastMessageAt: { _seconds: 2000 },
78
+ status: 'active'
79
+ });
80
+
81
+ const res = await request(app)
82
+ .get('/api/doctor/threads')
83
+ .set('Authorization', 'Bearer valid-doctor-token');
84
+
85
+ expect(res.status).toBe(200);
86
+ expect(res.body.threads).toHaveLength(1);
87
+ expect(res.body.threads[0].patientName).toBe('Alice');
88
+ });
89
+
90
+ it('should return empty array when no threads exist', async () => {
91
+ const res = await request(app)
92
+ .get('/api/doctor/threads')
93
+ .set('Authorization', 'Bearer valid-doctor-token');
94
+
95
+ expect(res.status).toBe(200);
96
+ expect(res.body.threads).toHaveLength(0);
97
+ });
98
+
99
+ it('should filter threads by triage category', async () => {
100
+ admin.__seed('threads', 't1', {
101
+ doctorId: 'doctor-uid-1',
102
+ triageCategory: 'urgent',
103
+ lastMessageAt: { _seconds: 1000 }
104
+ });
105
+ admin.__seed('threads', 't2', {
106
+ doctorId: 'doctor-uid-1',
107
+ triageCategory: 'informational',
108
+ lastMessageAt: { _seconds: 2000 }
109
+ });
110
+
111
+ const res = await request(app)
112
+ .get('/api/doctor/threads?triage=urgent')
113
+ .set('Authorization', 'Bearer valid-doctor-token');
114
+
115
+ expect(res.status).toBe(200);
116
+ expect(res.body.threads).toHaveLength(1);
117
+ expect(res.body.threads[0].triageCategory).toBe('urgent');
118
+ });
119
+ });
120
+
121
+ describe('GET /api/conversations (legacy)', () => {
122
+ it('should return conversations from the conversations collection', async () => {
123
+ admin.__seed('conversations', 'conv-1', {
124
+ providerId: 'doctor-uid-1',
125
+ clientName: 'Bob',
126
+ sessionId: 'session-legacy-1',
127
+ lastUserMessage: 'I need help',
128
+ updatedAt: { _seconds: 1000 }
129
+ });
130
+
131
+ const res = await request(app)
132
+ .get('/api/conversations?limit=50')
133
+ .set('Authorization', 'Bearer valid-doctor-token');
134
+
135
+ expect(res.status).toBe(200);
136
+ expect(res.body.conversations).toHaveLength(1);
137
+ expect(res.body.conversations[0].clientName).toBe('Bob');
138
+ });
139
+ });
140
+
141
+ describe('GET /api/doctor/thread/:threadId (detail)', () => {
142
+ it('should return thread detail with messages', async () => {
143
+ admin.__seed('threads', 'thread-detail-1', {
144
+ doctorId: 'doctor-uid-1',
145
+ patientName: 'Carol',
146
+ unreadByDoctor: 3,
147
+ status: 'active'
148
+ });
149
+ admin.__seed('threads/thread-detail-1/messages', 'msg-1', {
150
+ role: 'patient',
151
+ message: 'Help me',
152
+ createdAt: { _seconds: 1000 }
153
+ });
154
+
155
+ const res = await request(app)
156
+ .get('/api/doctor/thread/thread-detail-1')
157
+ .set('Authorization', 'Bearer valid-doctor-token');
158
+
159
+ expect(res.status).toBe(200);
160
+ expect(res.body.thread.patientName).toBe('Carol');
161
+ expect(res.body.messages).toHaveLength(1);
162
+ expect(res.body.messages[0].message).toBe('Help me');
163
+ });
164
+
165
+ it('should return 404 for another doctors thread', async () => {
166
+ admin.__seed('threads', 'other-thread', {
167
+ doctorId: 'other-doctor',
168
+ status: 'active'
169
+ });
170
+
171
+ const res = await request(app)
172
+ .get('/api/doctor/thread/other-thread')
173
+ .set('Authorization', 'Bearer valid-doctor-token');
174
+
175
+ expect(res.status).toBe(404);
176
+ });
177
+ });
178
+
179
+ describe('POST /api/doctor/message (reply)', () => {
180
+ it('should add a doctor reply to a thread', async () => {
181
+ admin.__seed('threads', 'thread-reply-1', {
182
+ doctorId: 'doctor-uid-1',
183
+ messageCount: 2,
184
+ status: 'active'
185
+ });
186
+
187
+ const res = await request(app)
188
+ .post('/api/doctor/message')
189
+ .set('Authorization', 'Bearer valid-doctor-token')
190
+ .send({ threadId: 'thread-reply-1', message: 'Take ibuprofen and rest.' });
191
+
192
+ expect(res.status).toBe(200);
193
+ expect(res.body.success).toBe(true);
194
+
195
+ // Verify message was added to subcollection
196
+ const msgs = admin.__getAll('threads/thread-reply-1/messages');
197
+ expect(Object.keys(msgs).length).toBe(1);
198
+ expect(Object.values(msgs)[0].role).toBe('doctor');
199
+ });
200
+ });
201
+
202
+ describe('REGRESSION: Doctor inbox must show both threads and conversations', () => {
203
+ /**
204
+ * This test documents the bug we fixed:
205
+ * The inbox frontend previously only loaded from /api/doctor/threads.
206
+ * Doctors with existing data in the conversations collection saw an empty inbox.
207
+ *
208
+ * While this API-level test verifies both endpoints return data independently,
209
+ * the actual fix was in the frontend (inbox-threads.js) fetching from both.
210
+ * This test ensures neither endpoint regresses.
211
+ */
212
+ it('both /api/doctor/threads and /api/conversations should return data for the same doctor', async () => {
213
+ // Doctor has data in BOTH collections
214
+ admin.__seed('threads', 'new-thread-1', {
215
+ doctorId: 'doctor-uid-1',
216
+ patientName: 'New Patient',
217
+ lastMessageAt: { _seconds: 3000 },
218
+ status: 'active'
219
+ });
220
+ admin.__seed('conversations', 'old-conv-1', {
221
+ providerId: 'doctor-uid-1',
222
+ clientName: 'Old Patient',
223
+ sessionId: 'legacy-session',
224
+ updatedAt: { _seconds: 1000 }
225
+ });
226
+
227
+ const [threadsRes, convosRes] = await Promise.all([
228
+ request(app)
229
+ .get('/api/doctor/threads')
230
+ .set('Authorization', 'Bearer valid-doctor-token'),
231
+ request(app)
232
+ .get('/api/conversations')
233
+ .set('Authorization', 'Bearer valid-doctor-token')
234
+ ]);
235
+
236
+ expect(threadsRes.status).toBe(200);
237
+ expect(convosRes.status).toBe(200);
238
+
239
+ expect(threadsRes.body.threads.length).toBeGreaterThanOrEqual(1);
240
+ expect(convosRes.body.conversations.length).toBeGreaterThanOrEqual(1);
241
+
242
+ // The inbox should show data from BOTH — this is what was broken
243
+ const totalItems = threadsRes.body.threads.length + convosRes.body.conversations.length;
244
+ expect(totalItems).toBe(2);
245
+ });
246
+
247
+ it('/api/conversations should still work even if threads collection is empty', async () => {
248
+ // Only legacy data — the exact scenario that broke the inbox
249
+ admin.__seed('conversations', 'legacy-1', {
250
+ providerId: 'doctor-uid-1',
251
+ clientName: 'Legacy Patient',
252
+ sessionId: 'session-old',
253
+ lastUserMessage: 'Hello doctor',
254
+ updatedAt: { _seconds: 1000 }
255
+ });
256
+
257
+ const threadsRes = await request(app)
258
+ .get('/api/doctor/threads')
259
+ .set('Authorization', 'Bearer valid-doctor-token');
260
+
261
+ const convosRes = await request(app)
262
+ .get('/api/conversations')
263
+ .set('Authorization', 'Bearer valid-doctor-token');
264
+
265
+ // Threads endpoint returns empty — this is fine
266
+ expect(threadsRes.body.threads).toHaveLength(0);
267
+
268
+ // But conversations endpoint MUST return the legacy data
269
+ expect(convosRes.body.conversations).toHaveLength(1);
270
+ expect(convosRes.body.conversations[0].clientName).toBe('Legacy Patient');
271
+ });
272
+
273
+ it('should search threads by patient name', async () => {
274
+ admin.__seed('threads', 'thread-search-1', {
275
+ doctorId: 'doctor-uid-1',
276
+ patientName: 'Jane Smith',
277
+ lastMessageAt: { _seconds: 2000 },
278
+ status: 'active'
279
+ });
280
+ admin.__seed('threads', 'thread-search-2', {
281
+ doctorId: 'doctor-uid-1',
282
+ patientName: 'Bob Jones',
283
+ lastMessageAt: { _seconds: 1000 },
284
+ status: 'active'
285
+ });
286
+
287
+ const res = await request(app)
288
+ .get('/api/doctor/threads?q=jane')
289
+ .set('Authorization', 'Bearer valid-doctor-token');
290
+
291
+ expect(res.status).toBe(200);
292
+ expect(res.body.threads.length).toBe(1);
293
+ expect(res.body.threads[0].patientName).toBe('Jane Smith');
294
+ });
295
+ });
296
+
297
+ describe('PATCH /api/doctor/thread/:threadId', () => {
298
+ it('should allow manual triage override', async () => {
299
+ admin.__seed('threads', 'thread-triage', {
300
+ doctorId: 'doctor-uid-1',
301
+ triageCategory: 'informational',
302
+ status: 'active'
303
+ });
304
+
305
+ const res = await request(app)
306
+ .patch('/api/doctor/thread/thread-triage')
307
+ .set('Authorization', 'Bearer valid-doctor-token')
308
+ .send({ triageCategory: 'urgent' });
309
+
310
+ expect(res.status).toBe(200);
311
+
312
+ const updated = admin.__getAll('threads')['thread-triage'];
313
+ expect(updated.triageCategory).toBe('urgent');
314
+ expect(updated.triageReason).toMatch(/Manual override/);
315
+ });
316
+
317
+ it('should allow resolving a thread', async () => {
318
+ admin.__seed('threads', 'thread-resolve', {
319
+ doctorId: 'doctor-uid-1',
320
+ status: 'active'
321
+ });
322
+
323
+ const res = await request(app)
324
+ .patch('/api/doctor/thread/thread-resolve')
325
+ .set('Authorization', 'Bearer valid-doctor-token')
326
+ .send({ status: 'resolved' });
327
+
328
+ expect(res.status).toBe(200);
329
+ const updated = admin.__getAll('threads')['thread-resolve'];
330
+ expect(updated.status).toBe('resolved');
331
+ });
332
+
333
+ it('should reject invalid status values', async () => {
334
+ admin.__seed('threads', 'thread-bad-status', {
335
+ doctorId: 'doctor-uid-1',
336
+ status: 'active'
337
+ });
338
+
339
+ const res = await request(app)
340
+ .patch('/api/doctor/thread/thread-bad-status')
341
+ .set('Authorization', 'Bearer valid-doctor-token')
342
+ .send({ status: 'HACKED' });
343
+
344
+ expect(res.status).toBe(200);
345
+ const updated = admin.__getAll('threads')['thread-bad-status'];
346
+ expect(updated.status).toBe('active');
347
+ });
348
+ });
349
+
350
+ describe('Draft endpoints', () => {
351
+ it('POST /api/doctor/draft/reject should reject a draft', async () => {
352
+ admin.__seed('threads', 'thread-draft', {
353
+ doctorId: 'doctor-uid-1',
354
+ aiDraftResponse: 'Draft response text',
355
+ aiDraftStatus: 'pending'
356
+ });
357
+
358
+ const res = await request(app)
359
+ .post('/api/doctor/draft/reject')
360
+ .set('Authorization', 'Bearer valid-doctor-token')
361
+ .send({ threadId: 'thread-draft' });
362
+
363
+ expect(res.status).toBe(200);
364
+
365
+ const updated = admin.__getAll('threads')['thread-draft'];
366
+ expect(updated.aiDraftStatus).toBe('rejected');
367
+ });
368
+ });
369
+
370
+ describe('Notification endpoints', () => {
371
+ it('GET /api/doctor/notifications should return notifications', async () => {
372
+ admin.__seed('notifications', 'notif-1', {
373
+ doctorId: 'doctor-uid-1',
374
+ type: 'escalation',
375
+ read: false,
376
+ createdAt: { _seconds: 1000 }
377
+ });
378
+
379
+ const res = await request(app)
380
+ .get('/api/doctor/notifications')
381
+ .set('Authorization', 'Bearer valid-doctor-token');
382
+
383
+ expect(res.status).toBe(200);
384
+ expect(res.body.notifications).toHaveLength(1);
385
+ expect(res.body.unreadCount).toBe(1);
386
+ });
387
+
388
+ it('POST /api/doctor/notifications/read should mark all as read', async () => {
389
+ admin.__seed('notifications', 'n1', { doctorId: 'doctor-uid-1', read: false });
390
+ admin.__seed('notifications', 'n2', { doctorId: 'doctor-uid-1', read: false });
391
+
392
+ const res = await request(app)
393
+ .post('/api/doctor/notifications/read')
394
+ .set('Authorization', 'Bearer valid-doctor-token')
395
+ .send({});
396
+
397
+ expect(res.status).toBe(200);
398
+ expect(res.body.success).toBe(true);
399
+ });
400
+ });
401
+ });