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,328 @@
1
+ process.env.NODE_ENV = 'test';
2
+
3
+ jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
4
+ jest.mock('stripe', () => jest.fn().mockReturnValue({}));
5
+ jest.mock('openai', () => jest.fn().mockImplementation(() => ({
6
+ chat: { completions: { create: jest.fn().mockResolvedValue({ choices: [{ message: { content: '{}' } }] }) } }
7
+ })));
8
+ jest.mock('@google/generative-ai', () => ({
9
+ GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
10
+ getGenerativeModel: () => ({ generateContent: jest.fn().mockResolvedValue({ response: { text: () => 'mock' } }) })
11
+ }))
12
+ }));
13
+ jest.mock('../../functions/src/stripe-config', () => ({ stripe: {} }));
14
+ jest.mock('../../functions/src/payment', () => ({
15
+ createConnectAccount: jest.fn(), createAccountLink: jest.fn(), refreshAccountStatus: jest.fn(),
16
+ setProviderPricing: jest.fn(), createCheckoutSession: jest.fn(), createPaymentIntent: jest.fn(),
17
+ createSubscription: jest.fn(), cancelSubscription: jest.fn(), checkPaymentAccess: jest.fn(),
18
+ getRevenueAnalytics: jest.fn(), handleStripeWebhook: jest.fn()
19
+ }));
20
+ process.env.DEEPSEEK_API_KEY = 'test-key';
21
+ process.env.GEMINI_API_KEY = 'test-key';
22
+
23
+ const request = require('supertest');
24
+ const admin = require('firebase-admin');
25
+
26
+ admin.auth().verifyIdToken = jest.fn(async (token) => {
27
+ if (token === 'owner-token') return { uid: 'owner-uid', email: 'owner@clinic.com' };
28
+ if (token === 'invitee-token') return { uid: 'invitee-uid', email: 'newdoc@clinic.com' };
29
+ if (token === 'admin-token') return { uid: 'admin-uid', email: 'admin@clinic.com' };
30
+ if (token === 'clinician-token') return { uid: 'clinician-uid', email: 'clin@clinic.com' };
31
+ throw new Error('Invalid token');
32
+ });
33
+
34
+ const app = require('../../server');
35
+
36
+ // Seed a clinic org (plan clinic = 5 seats) with owner already a member.
37
+ function seedClinic(plan = 'clinic', seats = 5) {
38
+ admin.__seed('providers', 'owner-uid', {
39
+ ownerUid: 'owner-uid', displayName: 'Dr Owner', handle: 'drowner',
40
+ plan, specialty: 'General', status: 'preview'
41
+ });
42
+ admin.__seed('organizations', 'owner-uid', {
43
+ ownerUid: 'owner-uid', name: 'Test Clinic', type: 'clinic', plan, seats
44
+ });
45
+ admin.__seed('organizations/owner-uid/members', 'owner-uid', {
46
+ uid: 'owner-uid', email: 'owner@clinic.com', role: 'owner', providerId: 'owner-uid', status: 'active'
47
+ });
48
+ admin.__seed('memberships', 'owner-uid', { orgId: 'owner-uid', role: 'owner' });
49
+ }
50
+
51
+ describe('Organizations & Team API', () => {
52
+ beforeEach(() => {
53
+ admin.__reset();
54
+ });
55
+
56
+ describe('GET /api/org', () => {
57
+ it('lazily creates a personal org for an existing provider', async () => {
58
+ admin.__seed('providers', 'owner-uid', {
59
+ ownerUid: 'owner-uid', displayName: 'Dr Solo', handle: 'drsolo', plan: 'free', status: 'preview'
60
+ });
61
+
62
+ const res = await request(app).get('/api/org').set('Authorization', 'Bearer owner-token');
63
+
64
+ expect(res.status).toBe(200);
65
+ expect(res.body.role).toBe('owner');
66
+ expect(res.body.org.type).toBe('personal');
67
+ expect(res.body.seatLimit).toBe(1);
68
+ expect(res.body.activeMembers).toBe(1);
69
+ });
70
+
71
+ it('returns 404 when user has no provider and no membership', async () => {
72
+ const res = await request(app).get('/api/org').set('Authorization', 'Bearer owner-token');
73
+ expect(res.status).toBe(404);
74
+ });
75
+
76
+ it('returns the seeded clinic org with correct seat math', async () => {
77
+ seedClinic();
78
+ const res = await request(app).get('/api/org').set('Authorization', 'Bearer owner-token');
79
+ expect(res.status).toBe(200);
80
+ expect(res.body.org.type).toBe('clinic');
81
+ expect(res.body.seatLimit).toBe(5);
82
+ expect(res.body.seatsRemaining).toBe(4);
83
+ });
84
+
85
+ it('rejects unauthenticated requests', async () => {
86
+ const res = await request(app).get('/api/org');
87
+ expect(res.status).toBe(401);
88
+ });
89
+ });
90
+
91
+ describe('POST /api/org/invite', () => {
92
+ it('owner can invite a clinician', async () => {
93
+ seedClinic();
94
+ const res = await request(app).post('/api/org/invite')
95
+ .set('Authorization', 'Bearer owner-token')
96
+ .send({ email: 'newdoc@clinic.com', role: 'clinician' });
97
+
98
+ expect(res.status).toBe(200);
99
+ expect(res.body.success).toBe(true);
100
+ expect(res.body.invite.status).toBe('pending');
101
+ expect(res.body.invite.token).toBeTruthy();
102
+ });
103
+
104
+ it('rejects an invalid email', async () => {
105
+ seedClinic();
106
+ const res = await request(app).post('/api/org/invite')
107
+ .set('Authorization', 'Bearer owner-token')
108
+ .send({ email: 'not-an-email', role: 'clinician' });
109
+ expect(res.status).toBe(400);
110
+ });
111
+
112
+ it('rejects assigning the owner role', async () => {
113
+ seedClinic();
114
+ const res = await request(app).post('/api/org/invite')
115
+ .set('Authorization', 'Bearer owner-token')
116
+ .send({ email: 'newdoc@clinic.com', role: 'owner' });
117
+ expect(res.status).toBe(403);
118
+ });
119
+
120
+ it('enforces the seat limit on a free plan', async () => {
121
+ seedClinic('free', 1);
122
+ const res = await request(app).post('/api/org/invite')
123
+ .set('Authorization', 'Bearer owner-token')
124
+ .send({ email: 'newdoc@clinic.com', role: 'clinician' });
125
+ expect(res.status).toBe(402);
126
+ expect(res.body.code).toBe('SEAT_LIMIT_REACHED');
127
+ });
128
+
129
+ it('a clinician cannot invite members', async () => {
130
+ seedClinic();
131
+ admin.__seed('organizations/owner-uid/members', 'clinician-uid', {
132
+ uid: 'clinician-uid', email: 'clin@clinic.com', role: 'clinician', status: 'active'
133
+ });
134
+ admin.__seed('memberships', 'clinician-uid', { orgId: 'owner-uid', role: 'clinician' });
135
+
136
+ const res = await request(app).post('/api/org/invite')
137
+ .set('Authorization', 'Bearer clinician-token')
138
+ .send({ email: 'another@clinic.com', role: 'clinician' });
139
+ expect(res.status).toBe(403);
140
+ });
141
+
142
+ it('rejects unauthenticated requests', async () => {
143
+ const res = await request(app).post('/api/org/invite').send({ email: 'x@y.com', role: 'clinician' });
144
+ expect(res.status).toBe(401);
145
+ });
146
+ });
147
+
148
+ describe('POST /api/org/invite/accept', () => {
149
+ it('invitee with matching email accepts and becomes a member', async () => {
150
+ seedClinic();
151
+ admin.__seed('invitations', 'tok123', {
152
+ orgId: 'owner-uid', email: 'newdoc@clinic.com', role: 'clinician',
153
+ token: 'tok123', status: 'pending', expiresAt: 9999999999999
154
+ });
155
+
156
+ const res = await request(app).post('/api/org/invite/accept')
157
+ .set('Authorization', 'Bearer invitee-token')
158
+ .send({ token: 'tok123' });
159
+ expect(res.status).toBe(200);
160
+ expect(res.body.role).toBe('clinician');
161
+
162
+ const members = await request(app).get('/api/org/members').set('Authorization', 'Bearer owner-token');
163
+ expect(members.body.members.some(m => m.uid === 'invitee-uid')).toBe(true);
164
+ });
165
+
166
+ it('rejects a user whose email does not match the invite', async () => {
167
+ seedClinic();
168
+ admin.__seed('invitations', 'tok123', {
169
+ orgId: 'owner-uid', email: 'someoneelse@clinic.com', role: 'clinician',
170
+ token: 'tok123', status: 'pending', expiresAt: 9999999999999
171
+ });
172
+ const res = await request(app).post('/api/org/invite/accept')
173
+ .set('Authorization', 'Bearer invitee-token')
174
+ .send({ token: 'tok123' });
175
+ expect(res.status).toBe(403);
176
+ });
177
+
178
+ it('returns 404 for an unknown token', async () => {
179
+ seedClinic();
180
+ const res = await request(app).post('/api/org/invite/accept')
181
+ .set('Authorization', 'Bearer invitee-token')
182
+ .send({ token: 'nope' });
183
+ expect(res.status).toBe(404);
184
+ });
185
+
186
+ it('rejects an already-accepted invite', async () => {
187
+ seedClinic();
188
+ admin.__seed('invitations', 'tok123', {
189
+ orgId: 'owner-uid', email: 'newdoc@clinic.com', role: 'clinician',
190
+ token: 'tok123', status: 'accepted', expiresAt: 9999999999999
191
+ });
192
+ const res = await request(app).post('/api/org/invite/accept')
193
+ .set('Authorization', 'Bearer invitee-token')
194
+ .send({ token: 'tok123' });
195
+ expect(res.status).toBe(409);
196
+ });
197
+ });
198
+
199
+ describe('PATCH /api/org/members/:uid', () => {
200
+ beforeEach(() => {
201
+ seedClinic();
202
+ admin.__seed('organizations/owner-uid/members', 'invitee-uid', {
203
+ uid: 'invitee-uid', email: 'newdoc@clinic.com', role: 'clinician', status: 'active'
204
+ });
205
+ admin.__seed('memberships', 'invitee-uid', { orgId: 'owner-uid', role: 'clinician' });
206
+ });
207
+
208
+ it('owner can promote a clinician to admin', async () => {
209
+ const res = await request(app).patch('/api/org/members/invitee-uid')
210
+ .set('Authorization', 'Bearer owner-token')
211
+ .send({ role: 'admin' });
212
+ expect(res.status).toBe(200);
213
+ expect(res.body.role).toBe('admin');
214
+ });
215
+
216
+ it('owner cannot assign the owner role', async () => {
217
+ const res = await request(app).patch('/api/org/members/invitee-uid')
218
+ .set('Authorization', 'Bearer owner-token')
219
+ .send({ role: 'owner' });
220
+ expect(res.status).toBe(403);
221
+ });
222
+
223
+ it('returns 404 for a non-existent member', async () => {
224
+ const res = await request(app).patch('/api/org/members/ghost-uid')
225
+ .set('Authorization', 'Bearer owner-token')
226
+ .send({ role: 'admin' });
227
+ expect(res.status).toBe(404);
228
+ });
229
+ });
230
+
231
+ describe('DELETE /api/org/members/:uid', () => {
232
+ beforeEach(() => {
233
+ seedClinic();
234
+ admin.__seed('organizations/owner-uid/members', 'invitee-uid', {
235
+ uid: 'invitee-uid', email: 'newdoc@clinic.com', role: 'clinician', status: 'active'
236
+ });
237
+ admin.__seed('memberships', 'invitee-uid', { orgId: 'owner-uid', role: 'clinician' });
238
+ });
239
+
240
+ it('owner can remove a clinician', async () => {
241
+ const res = await request(app).delete('/api/org/members/invitee-uid')
242
+ .set('Authorization', 'Bearer owner-token');
243
+ expect(res.status).toBe(200);
244
+
245
+ const members = await request(app).get('/api/org/members').set('Authorization', 'Bearer owner-token');
246
+ expect(members.body.members.some(m => m.uid === 'invitee-uid')).toBe(false);
247
+ });
248
+
249
+ it('cannot remove self', async () => {
250
+ const res = await request(app).delete('/api/org/members/owner-uid')
251
+ .set('Authorization', 'Bearer owner-token');
252
+ expect(res.status).toBe(400);
253
+ });
254
+
255
+ it('returns 404 for a non-existent member', async () => {
256
+ const res = await request(app).delete('/api/org/members/ghost-uid')
257
+ .set('Authorization', 'Bearer owner-token');
258
+ expect(res.status).toBe(404);
259
+ });
260
+ });
261
+
262
+ describe('GET /api/org/inbox (clinic-wide)', () => {
263
+ beforeEach(() => {
264
+ seedClinic();
265
+ // second provider/member in the clinic
266
+ admin.__seed('organizations/owner-uid/members', 'invitee-uid', {
267
+ uid: 'invitee-uid', email: 'newdoc@clinic.com', role: 'clinician', providerId: 'clin-provider', status: 'active'
268
+ });
269
+ admin.__seed('memberships', 'invitee-uid', { orgId: 'owner-uid', role: 'clinician' });
270
+ admin.__seed('threads', 't-owner', { doctorId: 'owner-uid', status: 'active', lastMessageAt: { _seconds: 200 }, lastUserMessage: 'hi' });
271
+ admin.__seed('threads', 't-clin', { doctorId: 'clin-provider', status: 'active', lastMessageAt: { _seconds: 100 }, lastUserMessage: 'hello' });
272
+ });
273
+
274
+ it('owner sees threads across all org providers', async () => {
275
+ const res = await request(app).get('/api/org/inbox').set('Authorization', 'Bearer owner-token');
276
+ expect(res.status).toBe(200);
277
+ const ids = res.body.threads.map(t => t.id);
278
+ expect(ids).toContain('t-owner');
279
+ expect(ids).toContain('t-clin');
280
+ expect(res.body.providerCount).toBe(2);
281
+ });
282
+
283
+ it('a clinician cannot view the clinic-wide inbox', async () => {
284
+ admin.__seed('organizations/owner-uid/members', 'clinician-uid', {
285
+ uid: 'clinician-uid', email: 'clin@clinic.com', role: 'clinician', providerId: 'x', status: 'active'
286
+ });
287
+ admin.__seed('memberships', 'clinician-uid', { orgId: 'owner-uid', role: 'clinician' });
288
+ const res = await request(app).get('/api/org/inbox').set('Authorization', 'Bearer clinician-token');
289
+ expect(res.status).toBe(403);
290
+ });
291
+
292
+ it('rejects unauthenticated', async () => {
293
+ const res = await request(app).get('/api/org/inbox');
294
+ expect(res.status).toBe(401);
295
+ });
296
+ });
297
+
298
+ describe('GET /api/org/analytics (clinic-wide)', () => {
299
+ beforeEach(() => {
300
+ seedClinic();
301
+ admin.__seed('providers', 'owner-uid', {
302
+ ownerUid: 'owner-uid', displayName: 'Dr Owner', handle: 'drowner', plan: 'clinic',
303
+ status: 'preview', totalChatCount: 30, totalEscalationCount: 3
304
+ });
305
+ admin.__seed('organizations/owner-uid/members', 'invitee-uid', {
306
+ uid: 'invitee-uid', email: 'newdoc@clinic.com', role: 'clinician', providerId: 'clin-provider', status: 'active'
307
+ });
308
+ admin.__seed('providers', 'clin-provider', {
309
+ ownerUid: 'invitee-uid', displayName: 'Dr Two', handle: 'drtwo', status: 'preview',
310
+ totalChatCount: 70, totalEscalationCount: 7
311
+ });
312
+ });
313
+
314
+ it('aggregates chats and escalations across providers', async () => {
315
+ const res = await request(app).get('/api/org/analytics').set('Authorization', 'Bearer owner-token');
316
+ expect(res.status).toBe(200);
317
+ expect(res.body.totalChats).toBe(100);
318
+ expect(res.body.totalEscalations).toBe(10);
319
+ expect(res.body.escalationRate).toBe(10);
320
+ expect(res.body.perProvider.length).toBe(2);
321
+ });
322
+
323
+ it('rejects unauthenticated', async () => {
324
+ const res = await request(app).get('/api/org/analytics');
325
+ expect(res.status).toBe(401);
326
+ });
327
+ });
328
+ });