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,397 @@
1
+ const authRequired = document.getElementById("authRequired");
2
+ const inboxContainer = document.getElementById("inboxContainer");
3
+ const conversationsList = document.getElementById("conversationsList");
4
+ const conversationDetail = document.getElementById("conversationDetail");
5
+ const replySection = document.getElementById("replySection");
6
+ const replyInput = document.getElementById("replyInput");
7
+ const sendReplyBtn = document.getElementById("sendReplyBtn");
8
+ const signOutBtn = document.getElementById("signOutBtn");
9
+
10
+ let currentProvider = null;
11
+ let conversations = [];
12
+ let selectedConversation = null;
13
+ let currentFilter = "all";
14
+
15
+ async function fetchWithAuth(path, options = {}) {
16
+ const user = firebase.auth().currentUser;
17
+ if (!user) throw new Error("Not authenticated");
18
+
19
+ const token = await user.getIdToken();
20
+ const headers = Object.assign({}, options.headers || {}, {
21
+ Authorization: `Bearer ${token}`,
22
+ });
23
+ return fetch(path, { ...options, headers });
24
+ }
25
+
26
+ async function loadProvider() {
27
+ try {
28
+ const res = await fetchWithAuth("/api/me");
29
+ if (res.status === 404) return null;
30
+ if (!res.ok) throw new Error("Unable to load provider");
31
+ const provider = await res.json();
32
+ return provider;
33
+ } catch (err) {
34
+ console.error("Error loading provider:", err);
35
+ return null;
36
+ }
37
+ }
38
+
39
+ async function loadConversations() {
40
+ try {
41
+ const res = await fetchWithAuth("/api/conversations?limit=100");
42
+ if (!res.ok) throw new Error("Unable to load conversations");
43
+ const data = await res.json();
44
+ conversations = data.conversations || [];
45
+ renderConversationsList();
46
+ } catch (err) {
47
+ console.error("Error loading conversations:", err);
48
+ conversationsList.innerHTML =
49
+ '<p style="color: #6b7280;">Unable to load conversations</p>';
50
+ }
51
+ }
52
+
53
+ function formatTime(timestamp) {
54
+ if (!timestamp) return "Unknown";
55
+ const date = timestamp.toDate
56
+ ? timestamp.toDate()
57
+ : new Date(timestamp._seconds * 1000);
58
+ const now = new Date();
59
+ const diff = now - date;
60
+
61
+ const minutes = Math.floor(diff / 60000);
62
+ const hours = Math.floor(diff / 3600000);
63
+ const days = Math.floor(diff / 86400000);
64
+
65
+ if (minutes < 60) return `${minutes}m ago`;
66
+ if (hours < 24) return `${hours}h ago`;
67
+ if (days < 7) return `${days}d ago`;
68
+
69
+ return date.toLocaleDateString();
70
+ }
71
+
72
+ function getClientIdentifier(conversation) {
73
+ if (conversation.clientName) return conversation.clientName;
74
+ if (conversation.clientEmail) return conversation.clientEmail;
75
+ if (conversation.clientPhone) return conversation.clientPhone;
76
+ if (conversation.sessionId) return `Session ${conversation.sessionId.substring(0, 8)}`;
77
+ return "Anonymous Patient";
78
+ }
79
+
80
+ function getMessagePreview(conversation) {
81
+ const lastMessage = conversation.lastUserMessage || conversation.lastAssistantMessage || "";
82
+ if (lastMessage) {
83
+ return (
84
+ lastMessage.substring(0, 60) +
85
+ (lastMessage.length > 60 ? "..." : "")
86
+ );
87
+ }
88
+ return "No messages yet";
89
+ }
90
+
91
+ function filterConversations() {
92
+ const now = new Date();
93
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
94
+ const weekStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
95
+
96
+ return conversations.filter((conv) => {
97
+ if (currentFilter === "all") return true;
98
+ if (currentFilter === "escalated") return conv.isEscalated === true;
99
+ if (currentFilter === "today") {
100
+ const convDate = conv.lastMessageAt?.toDate
101
+ ? conv.lastMessageAt.toDate()
102
+ : new Date(conv.lastMessageAt?._seconds * 1000);
103
+ return convDate >= todayStart;
104
+ }
105
+ if (currentFilter === "week") {
106
+ const convDate = conv.lastMessageAt?.toDate
107
+ ? conv.lastMessageAt.toDate()
108
+ : new Date(conv.lastMessageAt?._seconds * 1000);
109
+ return convDate >= weekStart;
110
+ }
111
+ return true;
112
+ });
113
+ }
114
+
115
+ function renderConversationsList() {
116
+ const filtered = filterConversations();
117
+
118
+ if (filtered.length === 0) {
119
+ conversationsList.innerHTML =
120
+ '<p style="color: rgba(255,255,255,0.5); text-align: center; margin-top: 2rem;">No conversations found</p>';
121
+ return;
122
+ }
123
+
124
+ conversationsList.innerHTML = "";
125
+ filtered.forEach((conv) => {
126
+ const item = document.createElement("div");
127
+ item.className = "conversation-item";
128
+ if (selectedConversation && selectedConversation.id === conv.id) {
129
+ item.classList.add("active");
130
+ }
131
+
132
+ const clientId = getClientIdentifier(conv);
133
+ const preview = getMessagePreview(conv);
134
+ const time = formatTime(
135
+ conv.lastMessageAt || conv.updatedAt || conv.createdAt
136
+ );
137
+
138
+ const header = document.createElement("div");
139
+ header.className = "conversation-header";
140
+ const clientDiv = document.createElement("div");
141
+ clientDiv.className = "conversation-client";
142
+ clientDiv.textContent = clientId;
143
+ const timeDiv = document.createElement("div");
144
+ timeDiv.className = "conversation-time";
145
+ timeDiv.textContent = time;
146
+ header.appendChild(clientDiv);
147
+ header.appendChild(timeDiv);
148
+ item.appendChild(header);
149
+
150
+ const topicDiv = document.createElement("div");
151
+ topicDiv.className = "conversation-topic";
152
+ topicDiv.textContent = (conv.currentTopic || "general_health_info").replace(/_/g, " ");
153
+ item.appendChild(topicDiv);
154
+
155
+ const previewDiv = document.createElement("div");
156
+ previewDiv.className = "conversation-preview";
157
+ previewDiv.textContent = preview;
158
+ item.appendChild(previewDiv);
159
+
160
+ if (conv.isEscalated) {
161
+ const badge = document.createElement("span");
162
+ badge.className = "conversation-badge escalated";
163
+ badge.textContent = "⚠️ Escalated";
164
+ item.appendChild(badge);
165
+ }
166
+ if (conv.unreadByDoctor) {
167
+ const badge = document.createElement("span");
168
+ badge.className = "conversation-badge";
169
+ badge.style.cssText = "background: var(--primary); color: white;";
170
+ badge.textContent = "New";
171
+ item.appendChild(badge);
172
+ }
173
+
174
+ item.addEventListener("click", () => {
175
+ selectedConversation = conv;
176
+ renderConversationsList();
177
+ renderConversationDetail(conv);
178
+ });
179
+
180
+ conversationsList.appendChild(item);
181
+ });
182
+ }
183
+
184
+ async function renderConversationDetail(conversation) {
185
+ conversationDetail.innerHTML =
186
+ '<div style="text-align: center; padding: 2rem;">Loading...</div>';
187
+ replySection.classList.add("hidden");
188
+
189
+ try {
190
+ // Fetch full conversation details with messages
191
+ const res = await fetchWithAuth(`/api/conversation/${conversation.id}`);
192
+ if (!res.ok) throw new Error("Unable to load conversation details");
193
+ const fullConv = await res.json();
194
+ const messages = fullConv.messages || [];
195
+
196
+ const clientId = getClientIdentifier(fullConv);
197
+
198
+ conversationDetail.innerHTML = "";
199
+
200
+ const detailHeader = document.createElement("div");
201
+ detailHeader.className = "detail-header";
202
+
203
+ const titleDiv = document.createElement("div");
204
+ titleDiv.className = "detail-title";
205
+ titleDiv.textContent = clientId;
206
+ detailHeader.appendChild(titleDiv);
207
+
208
+ const metaDiv = document.createElement("div");
209
+ metaDiv.className = "detail-meta";
210
+ const msgCountItem = document.createElement("div");
211
+ msgCountItem.className = "detail-meta-item";
212
+ const msgCountLabel = document.createElement("span");
213
+ msgCountLabel.className = "detail-meta-label";
214
+ msgCountLabel.textContent = `${messages.length} msgs`;
215
+ msgCountItem.appendChild(msgCountLabel);
216
+ metaDiv.appendChild(msgCountItem);
217
+
218
+ const timeItem = document.createElement("div");
219
+ timeItem.className = "detail-meta-item";
220
+ const timeDot = document.createElement("span");
221
+ timeDot.className = "detail-meta-label";
222
+ timeDot.textContent = "•";
223
+ const timeValue = document.createElement("span");
224
+ timeValue.className = "detail-meta-value";
225
+ timeValue.textContent = formatTime(fullConv.lastMessageAt || fullConv.createdAt);
226
+ timeItem.appendChild(timeDot);
227
+ timeItem.appendChild(timeValue);
228
+ metaDiv.appendChild(timeItem);
229
+
230
+ if (fullConv.isEscalated) {
231
+ const escItem = document.createElement("div");
232
+ escItem.className = "detail-meta-item";
233
+ const escDot = document.createElement("span");
234
+ escDot.className = "detail-meta-label";
235
+ escDot.textContent = "•";
236
+ const escVal = document.createElement("span");
237
+ escVal.className = "detail-meta-value";
238
+ escVal.style.color = "#ff6b6b";
239
+ escVal.textContent = "⚠️ Escalated";
240
+ escItem.appendChild(escDot);
241
+ escItem.appendChild(escVal);
242
+ metaDiv.appendChild(escItem);
243
+ }
244
+ detailHeader.appendChild(metaDiv);
245
+
246
+ if (fullConv.clientEmail || fullConv.clientPhone || fullConv.clientName) {
247
+ const contactBox = document.createElement("div");
248
+ contactBox.style.cssText = "margin-top: 0.75rem; padding: 0.75rem; background: #e6f7f4; border-radius: 6px; border-left: 3px solid #297B56; font-size: 0.85rem; color: #111827;";
249
+ if (fullConv.clientName) {
250
+ const nameDiv = document.createElement("div");
251
+ nameDiv.style.cssText = "margin-bottom: 0.25rem; font-weight: 600;";
252
+ nameDiv.textContent = fullConv.clientName;
253
+ contactBox.appendChild(nameDiv);
254
+ }
255
+ if (fullConv.clientEmail) {
256
+ const emailDiv = document.createElement("div");
257
+ emailDiv.style.marginBottom = "0.25rem";
258
+ const emailLink = document.createElement("a");
259
+ emailLink.href = `mailto:${fullConv.clientEmail}`;
260
+ emailLink.style.cssText = "color: #297B56; text-decoration: underline;";
261
+ emailLink.textContent = fullConv.clientEmail;
262
+ emailDiv.appendChild(emailLink);
263
+ contactBox.appendChild(emailDiv);
264
+ }
265
+ if (fullConv.clientPhone) {
266
+ const phoneDiv = document.createElement("div");
267
+ const phoneLink = document.createElement("a");
268
+ phoneLink.href = `tel:${fullConv.clientPhone}`;
269
+ phoneLink.style.cssText = "color: #297B56; text-decoration: underline;";
270
+ phoneLink.textContent = fullConv.clientPhone;
271
+ phoneDiv.appendChild(phoneLink);
272
+ contactBox.appendChild(phoneDiv);
273
+ }
274
+ detailHeader.appendChild(contactBox);
275
+ }
276
+
277
+ conversationDetail.appendChild(detailHeader);
278
+
279
+ const messagesList = document.createElement("div");
280
+ messagesList.className = "messages-list";
281
+
282
+ if (messages.length === 0) {
283
+ const emptyMsg = document.createElement("p");
284
+ emptyMsg.style.cssText = "text-align: center; color: #6b7280;";
285
+ emptyMsg.textContent = "No messages in this conversation";
286
+ messagesList.appendChild(emptyMsg);
287
+ } else {
288
+ messages.forEach((msg) => {
289
+ const isUser = msg.role === "patient" || msg.role === "user";
290
+ const roleClass = isUser ? "user" : msg.isDoctorManual ? "doctor" : "assistant";
291
+ const label = isUser ? "👤 Patient" : msg.isDoctorManual ? "👨‍⚕️ You" : "🤖 AI Assistant";
292
+
293
+ const block = document.createElement("div");
294
+ block.className = "message-block";
295
+ const labelDiv = document.createElement("div");
296
+ labelDiv.className = "message-label";
297
+ labelDiv.textContent = label;
298
+ block.appendChild(labelDiv);
299
+ const contentDiv = document.createElement("div");
300
+ contentDiv.className = `message-content ${roleClass}`;
301
+ contentDiv.textContent = msg.message || msg.content || "";
302
+ block.appendChild(contentDiv);
303
+ messagesList.appendChild(block);
304
+ });
305
+ }
306
+
307
+ conversationDetail.appendChild(messagesList);
308
+
309
+ // Scroll to bottom
310
+ conversationDetail.scrollTop = conversationDetail.scrollHeight;
311
+
312
+ // Show reply section
313
+ replySection.classList.remove("hidden");
314
+ } catch (err) {
315
+ console.error("Error loading conversation detail:", err);
316
+ conversationDetail.innerHTML =
317
+ '<div style="text-align: center; color: #ff6b6b; padding: 2rem;">Unable to load conversation details</div>';
318
+ }
319
+ }
320
+
321
+ async function sendReply() {
322
+ if (!selectedConversation) return;
323
+ const message = replyInput.value.trim();
324
+ if (!message) return;
325
+
326
+ sendReplyBtn.disabled = true;
327
+ sendReplyBtn.textContent = "Sending...";
328
+
329
+ try {
330
+ const res = await fetchWithAuth(`/api/conversation/${selectedConversation.id}/reply`, {
331
+ method: "POST",
332
+ headers: { "Content-Type": "application/json" },
333
+ body: JSON.stringify({ message }),
334
+ });
335
+
336
+ if (!res.ok) throw new Error("Failed to send message");
337
+
338
+ replyInput.value = "";
339
+ // Reload details to show new message
340
+ await renderConversationDetail(selectedConversation);
341
+ // Reload list to update preview/time
342
+ loadConversations();
343
+ } catch (err) {
344
+ console.error("Error sending reply:", err);
345
+ alert("Failed to send reply");
346
+ } finally {
347
+ sendReplyBtn.disabled = false;
348
+ sendReplyBtn.textContent = "Send Reply";
349
+ }
350
+ }
351
+
352
+ // Filter buttons
353
+ document.querySelectorAll(".filter-btn").forEach((btn) => {
354
+ btn.addEventListener("click", () => {
355
+ document
356
+ .querySelectorAll(".filter-btn")
357
+ .forEach((b) => b.classList.remove("active"));
358
+ btn.classList.add("active");
359
+ currentFilter = btn.dataset.filter;
360
+ renderConversationsList();
361
+ });
362
+ });
363
+
364
+ // Reply button
365
+ if (sendReplyBtn) {
366
+ sendReplyBtn.addEventListener("click", sendReply);
367
+ }
368
+
369
+ // Sign out
370
+ signOutBtn.addEventListener("click", async () => {
371
+ await firebase.auth().signOut();
372
+ window.location.href = "/app";
373
+ });
374
+
375
+ // Initialize
376
+ firebase.auth().onAuthStateChanged(async (user) => {
377
+ if (!user) {
378
+ authRequired.classList.remove("hidden");
379
+ inboxContainer.classList.add("hidden");
380
+ return;
381
+ }
382
+
383
+ authRequired.classList.add("hidden");
384
+ inboxContainer.classList.remove("hidden");
385
+
386
+ currentProvider = await loadProvider();
387
+ if (!currentProvider) {
388
+ authRequired.classList.remove("hidden");
389
+ inboxContainer.classList.add("hidden");
390
+ return;
391
+ }
392
+
393
+ await loadConversations();
394
+
395
+ // Auto-refresh every 30 seconds
396
+ setInterval(loadConversations, 30000);
397
+ });