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
+ /**
2
+ * Unit tests for the Organizations & RBAC pure-logic module.
3
+ * Module under test: functions/src/orgs.js
4
+ */
5
+
6
+ const {
7
+ ROLES,
8
+ ROLE_RANK,
9
+ PERMISSIONS,
10
+ SEAT_LIMITS,
11
+ can,
12
+ isValidRole,
13
+ canManageMember,
14
+ canAssignRole,
15
+ seatLimitForPlan,
16
+ seatsRemaining,
17
+ hasSeatAvailable,
18
+ personalOrgDefaults
19
+ } = require('../../functions/src/orgs');
20
+
21
+ describe('ROLES / ROLE_RANK', () => {
22
+ test('exactly 4 roles exist', () => {
23
+ expect(ROLES).toHaveLength(4);
24
+ expect(ROLES).toEqual(expect.arrayContaining(['owner', 'admin', 'clinician', 'staff']));
25
+ });
26
+
27
+ test('ranks order: owner > admin > clinician > staff', () => {
28
+ expect(ROLE_RANK.owner).toBeGreaterThan(ROLE_RANK.admin);
29
+ expect(ROLE_RANK.admin).toBeGreaterThan(ROLE_RANK.clinician);
30
+ expect(ROLE_RANK.clinician).toBeGreaterThan(ROLE_RANK.staff);
31
+ });
32
+
33
+ test('every role has a numeric rank', () => {
34
+ ROLES.forEach((r) => {
35
+ expect(typeof ROLE_RANK[r]).toBe('number');
36
+ });
37
+ });
38
+ });
39
+
40
+ describe('can(role, action) — permission matrix', () => {
41
+ describe('owner can do every defined action', () => {
42
+ const actions = [
43
+ 'billing.manage',
44
+ 'org.delete',
45
+ 'members.invite',
46
+ 'members.remove',
47
+ 'members.role',
48
+ 'inbox.viewAll',
49
+ 'settings.edit',
50
+ 'provider.own',
51
+ 'inbox.viewOwn',
52
+ 'inbox.reply'
53
+ ];
54
+ test.each(actions)('owner can %s', (action) => {
55
+ expect(can('owner', action)).toBe(true);
56
+ });
57
+
58
+ test('owner is allowed on every action in PERMISSIONS', () => {
59
+ Object.keys(PERMISSIONS).forEach((action) => {
60
+ expect(can('owner', action)).toBe(true);
61
+ });
62
+ });
63
+ });
64
+
65
+ describe('admin', () => {
66
+ const allowed = [
67
+ 'members.invite',
68
+ 'members.remove',
69
+ 'members.role',
70
+ 'inbox.viewAll',
71
+ 'settings.edit',
72
+ 'provider.own',
73
+ 'inbox.viewOwn',
74
+ 'inbox.reply'
75
+ ];
76
+ test.each(allowed)('admin CAN %s', (action) => {
77
+ expect(can('admin', action)).toBe(true);
78
+ });
79
+
80
+ test('admin CANNOT billing.manage', () => {
81
+ expect(can('admin', 'billing.manage')).toBe(false);
82
+ });
83
+ test('admin CANNOT org.delete', () => {
84
+ expect(can('admin', 'org.delete')).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe('clinician', () => {
89
+ const allowed = ['provider.own', 'inbox.viewOwn', 'inbox.reply'];
90
+ test.each(allowed)('clinician CAN %s', (action) => {
91
+ expect(can('clinician', action)).toBe(true);
92
+ });
93
+
94
+ const denied = [
95
+ 'members.invite',
96
+ 'inbox.viewAll',
97
+ 'settings.edit',
98
+ 'billing.manage',
99
+ 'org.delete'
100
+ ];
101
+ test.each(denied)('clinician CANNOT %s', (action) => {
102
+ expect(can('clinician', action)).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe('staff', () => {
107
+ const allowed = ['inbox.viewOwn', 'inbox.reply'];
108
+ test.each(allowed)('staff CAN %s', (action) => {
109
+ expect(can('staff', action)).toBe(true);
110
+ });
111
+
112
+ const denied = [
113
+ 'provider.own',
114
+ 'members.invite',
115
+ 'settings.edit',
116
+ 'inbox.viewAll'
117
+ ];
118
+ test.each(denied)('staff CANNOT %s', (action) => {
119
+ expect(can('staff', action)).toBe(false);
120
+ });
121
+ });
122
+
123
+ test('unknown action returns false', () => {
124
+ expect(can('owner', 'does.not.exist')).toBe(false);
125
+ expect(can('owner', '')).toBe(false);
126
+ expect(can('owner', undefined)).toBe(false);
127
+ });
128
+
129
+ test('unknown role returns false', () => {
130
+ expect(can('hacker', 'inbox.reply')).toBe(false);
131
+ expect(can('', 'inbox.reply')).toBe(false);
132
+ expect(can(null, 'inbox.reply')).toBe(false);
133
+ expect(can(undefined, 'inbox.reply')).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe('isValidRole', () => {
138
+ test.each(['owner', 'admin', 'clinician', 'staff'])('true for %s', (role) => {
139
+ expect(isValidRole(role)).toBe(true);
140
+ });
141
+
142
+ test('false for unrecognized / falsy values', () => {
143
+ expect(isValidRole('hacker')).toBe(false);
144
+ expect(isValidRole('')).toBe(false);
145
+ expect(isValidRole(null)).toBe(false);
146
+ expect(isValidRole(undefined)).toBe(false);
147
+ });
148
+ });
149
+
150
+ describe('canManageMember(actor, target)', () => {
151
+ test('owner can manage admin, clinician, staff', () => {
152
+ expect(canManageMember('owner', 'admin')).toBe(true);
153
+ expect(canManageMember('owner', 'clinician')).toBe(true);
154
+ expect(canManageMember('owner', 'staff')).toBe(true);
155
+ });
156
+
157
+ test('owner cannot manage another owner (equal rank)', () => {
158
+ expect(canManageMember('owner', 'owner')).toBe(false);
159
+ });
160
+
161
+ test('admin can manage clinician, staff', () => {
162
+ expect(canManageMember('admin', 'clinician')).toBe(true);
163
+ expect(canManageMember('admin', 'staff')).toBe(true);
164
+ });
165
+
166
+ test('admin cannot manage owner or another admin', () => {
167
+ expect(canManageMember('admin', 'owner')).toBe(false);
168
+ expect(canManageMember('admin', 'admin')).toBe(false);
169
+ });
170
+
171
+ test('clinician cannot manage anyone', () => {
172
+ expect(canManageMember('clinician', 'staff')).toBe(false);
173
+ expect(canManageMember('clinician', 'clinician')).toBe(false);
174
+ expect(canManageMember('clinician', 'admin')).toBe(false);
175
+ expect(canManageMember('clinician', 'owner')).toBe(false);
176
+ });
177
+
178
+ test('staff cannot manage anyone', () => {
179
+ expect(canManageMember('staff', 'staff')).toBe(false);
180
+ expect(canManageMember('staff', 'clinician')).toBe(false);
181
+ expect(canManageMember('staff', 'admin')).toBe(false);
182
+ expect(canManageMember('staff', 'owner')).toBe(false);
183
+ });
184
+
185
+ test('invalid roles return false', () => {
186
+ expect(canManageMember('hacker', 'staff')).toBe(false);
187
+ expect(canManageMember('owner', 'hacker')).toBe(false);
188
+ expect(canManageMember(null, 'staff')).toBe(false);
189
+ expect(canManageMember('owner', undefined)).toBe(false);
190
+ expect(canManageMember('', '')).toBe(false);
191
+ });
192
+ });
193
+
194
+ describe('canAssignRole(actor, newRole)', () => {
195
+ test('owner can assign admin, clinician, staff', () => {
196
+ expect(canAssignRole('owner', 'admin')).toBe(true);
197
+ expect(canAssignRole('owner', 'clinician')).toBe(true);
198
+ expect(canAssignRole('owner', 'staff')).toBe(true);
199
+ });
200
+
201
+ test('owner cannot assign owner (no minting owners)', () => {
202
+ expect(canAssignRole('owner', 'owner')).toBe(false);
203
+ });
204
+
205
+ test('admin can assign clinician, staff', () => {
206
+ expect(canAssignRole('admin', 'clinician')).toBe(true);
207
+ expect(canAssignRole('admin', 'staff')).toBe(true);
208
+ });
209
+
210
+ test('admin cannot assign admin or owner', () => {
211
+ expect(canAssignRole('admin', 'admin')).toBe(false);
212
+ expect(canAssignRole('admin', 'owner')).toBe(false);
213
+ });
214
+
215
+ test('clinician cannot assign anything', () => {
216
+ expect(canAssignRole('clinician', 'staff')).toBe(false);
217
+ expect(canAssignRole('clinician', 'clinician')).toBe(false);
218
+ expect(canAssignRole('clinician', 'admin')).toBe(false);
219
+ expect(canAssignRole('clinician', 'owner')).toBe(false);
220
+ });
221
+
222
+ test('staff cannot assign anything', () => {
223
+ expect(canAssignRole('staff', 'staff')).toBe(false);
224
+ expect(canAssignRole('staff', 'clinician')).toBe(false);
225
+ expect(canAssignRole('staff', 'admin')).toBe(false);
226
+ expect(canAssignRole('staff', 'owner')).toBe(false);
227
+ });
228
+
229
+ test('invalid roles return false', () => {
230
+ expect(canAssignRole('hacker', 'staff')).toBe(false);
231
+ expect(canAssignRole('owner', 'hacker')).toBe(false);
232
+ expect(canAssignRole(null, 'staff')).toBe(false);
233
+ expect(canAssignRole('owner', undefined)).toBe(false);
234
+ expect(canAssignRole('', '')).toBe(false);
235
+ });
236
+ });
237
+
238
+ describe('seatLimitForPlan', () => {
239
+ test('free = 1', () => {
240
+ expect(seatLimitForPlan('free')).toBe(1);
241
+ });
242
+ test('pro = 1', () => {
243
+ expect(seatLimitForPlan('pro')).toBe(1);
244
+ });
245
+ test('clinic = 5', () => {
246
+ expect(seatLimitForPlan('clinic')).toBe(5);
247
+ });
248
+ test('enterprise = null (unlimited)', () => {
249
+ expect(seatLimitForPlan('enterprise')).toBeNull();
250
+ });
251
+ test('unknown plan defaults to 1', () => {
252
+ expect(seatLimitForPlan('mystery')).toBe(1);
253
+ expect(seatLimitForPlan('')).toBe(1);
254
+ expect(seatLimitForPlan(undefined)).toBe(1);
255
+ });
256
+ });
257
+
258
+ describe('seatsRemaining', () => {
259
+ test('free with 1 member = 0', () => {
260
+ expect(seatsRemaining('free', 1)).toBe(0);
261
+ });
262
+ test('clinic with 2 members = 3', () => {
263
+ expect(seatsRemaining('clinic', 2)).toBe(3);
264
+ });
265
+ test('clinic with 5 members = 0', () => {
266
+ expect(seatsRemaining('clinic', 5)).toBe(0);
267
+ });
268
+ test('clinic with 6 members = 0 (clamped, never negative)', () => {
269
+ expect(seatsRemaining('clinic', 6)).toBe(0);
270
+ });
271
+ test('enterprise with 100 members = Infinity', () => {
272
+ expect(seatsRemaining('enterprise', 100)).toBe(Infinity);
273
+ });
274
+ test('free with 0 members = 1', () => {
275
+ expect(seatsRemaining('free', 0)).toBe(1);
276
+ });
277
+ });
278
+
279
+ describe('hasSeatAvailable', () => {
280
+ test('clinic with 4 members = true', () => {
281
+ expect(hasSeatAvailable('clinic', 4)).toBe(true);
282
+ });
283
+ test('clinic with 5 members = false', () => {
284
+ expect(hasSeatAvailable('clinic', 5)).toBe(false);
285
+ });
286
+ test('enterprise always true', () => {
287
+ expect(hasSeatAvailable('enterprise', 0)).toBe(true);
288
+ expect(hasSeatAvailable('enterprise', 1000000)).toBe(true);
289
+ });
290
+ test('free with 0 = true', () => {
291
+ expect(hasSeatAvailable('free', 0)).toBe(true);
292
+ });
293
+ test('free with 1 = false', () => {
294
+ expect(hasSeatAvailable('free', 1)).toBe(false);
295
+ });
296
+ });
297
+
298
+ describe('personalOrgDefaults', () => {
299
+ test('returns the expected default shape', () => {
300
+ const org = personalOrgDefaults({ orgId: 'org_1', ownerUid: 'uid_1' });
301
+ expect(org.type).toBe('personal');
302
+ expect(org.plan).toBe('free');
303
+ expect(org.seats).toBe(1);
304
+ expect(org.id).toBe('org_1');
305
+ expect(org.ownerUid).toBe('uid_1');
306
+ });
307
+
308
+ test('ownerUid passed through', () => {
309
+ const org = personalOrgDefaults({ orgId: 'o', ownerUid: 'abc123' });
310
+ expect(org.ownerUid).toBe('abc123');
311
+ });
312
+
313
+ test('name defaults to "My Practice" when omitted', () => {
314
+ const org = personalOrgDefaults({ orgId: 'o', ownerUid: 'u' });
315
+ expect(org.name).toBe('My Practice');
316
+ });
317
+
318
+ test('uses provided name when supplied', () => {
319
+ const org = personalOrgDefaults({ orgId: 'o', ownerUid: 'u', name: 'Downtown Clinic' });
320
+ expect(org.name).toBe('Downtown Clinic');
321
+ });
322
+
323
+ test('createdAt and updatedAt are null (stamped by caller)', () => {
324
+ const org = personalOrgDefaults({ orgId: 'o', ownerUid: 'u' });
325
+ expect(org.createdAt).toBeNull();
326
+ expect(org.updatedAt).toBeNull();
327
+ });
328
+ });