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,843 @@
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 signOutBtn = document.getElementById('signOutBtn');
6
+
7
+ let currentProvider = null;
8
+ let threads = [];
9
+ let selectedThread = null;
10
+ let currentFilter = 'all';
11
+
12
+ // ============================================
13
+ // AUTH & FETCH HELPERS
14
+ // ============================================
15
+
16
+ async function fetchWithAuth(path, options = {}) {
17
+ const user = firebase.auth().currentUser;
18
+ if (!user) throw new Error('Not authenticated');
19
+
20
+ const token = await user.getIdToken();
21
+ const headers = Object.assign({}, options.headers || {}, {
22
+ Authorization: `Bearer ${token}`
23
+ });
24
+ return fetch(path, { ...options, headers });
25
+ }
26
+
27
+ async function loadProvider() {
28
+ try {
29
+ const res = await fetchWithAuth('/api/me');
30
+ if (res.status === 404) return null;
31
+ if (!res.ok) throw new Error('Unable to load provider');
32
+ return await res.json();
33
+ } catch (err) {
34
+ console.error('Error loading provider:', err);
35
+ return null;
36
+ }
37
+ }
38
+
39
+ // ============================================
40
+ // THREADS
41
+ // ============================================
42
+
43
+ async function loadThreads() {
44
+ try {
45
+ // Fetch from both threads (new) and conversations (legacy) in parallel
46
+ const [threadsRes, convosRes] = await Promise.allSettled([
47
+ fetchWithAuth('/api/doctor/threads?limit=100'),
48
+ fetchWithAuth('/api/conversations?limit=100')
49
+ ]);
50
+
51
+ let newThreads = [];
52
+ let legacyConvos = [];
53
+
54
+ if (threadsRes.status === 'fulfilled' && threadsRes.value.ok) {
55
+ const data = await threadsRes.value.json();
56
+ newThreads = (data.threads || []).map(t => ({ ...t, _source: 'threads' }));
57
+ }
58
+
59
+ if (convosRes.status === 'fulfilled' && convosRes.value.ok) {
60
+ const data = await convosRes.value.json();
61
+ legacyConvos = (data.conversations || []).map(c => ({
62
+ id: c.id,
63
+ patientName: c.clientName || null,
64
+ patientEmail: c.clientEmail || null,
65
+ clientPhone: c.clientPhone || null,
66
+ patientSource: 'anonymous',
67
+ sessionId: c.sessionId,
68
+ doctorId: c.providerId,
69
+ status: 'active',
70
+ isEscalated: c.isEscalated || false,
71
+ lastMessageAt: c.lastMessageAt || c.updatedAt,
72
+ lastMessageText: c.lastUserMessage || c.lastAssistantMessage || '',
73
+ lastMessageRole: null,
74
+ messageCount: c.messageCount || 0,
75
+ unreadByDoctor: c.unreadByDoctor || 0,
76
+ currentTopic: c.currentTopic,
77
+ createdAt: c.createdAt,
78
+ _source: 'conversations'
79
+ }));
80
+ }
81
+
82
+ // Deduplicate: threads take priority over conversations with same sessionId
83
+ const threadSessionIds = new Set(newThreads.map(t => t.sessionId).filter(Boolean));
84
+ legacyConvos = legacyConvos.filter(c => !c.sessionId || !threadSessionIds.has(c.sessionId));
85
+
86
+ // Merge and sort by most recent
87
+ threads = [...newThreads, ...legacyConvos];
88
+ threads.sort((a, b) => {
89
+ const aTime = getTimestampSeconds(a.lastMessageAt || a.createdAt);
90
+ const bTime = getTimestampSeconds(b.lastMessageAt || b.createdAt);
91
+ return bTime - aTime;
92
+ });
93
+
94
+ renderThreadsList();
95
+ } catch (err) {
96
+ console.error('Error loading threads:', err);
97
+ conversationsList.innerHTML = '<div style="color: #6b7280; text-align: center; margin-top: 2rem;"><p>Unable to load conversations</p><button onclick="loadThreads()" class="filter-btn" style="margin-top:0.5rem;">Retry</button></div>';
98
+ }
99
+ }
100
+
101
+ // ============================================
102
+ // FORMATTING HELPERS
103
+ // ============================================
104
+
105
+ function escapeHtml(text) {
106
+ const div = document.createElement('div');
107
+ div.textContent = text;
108
+ return div.innerHTML;
109
+ }
110
+
111
+ function linkifyUrls(text) {
112
+ return text.replace(
113
+ /(https?:\/\/[^\s<]+)/g,
114
+ '<a href="$1" target="_blank" rel="noopener noreferrer" style="color:#297B56;">$1</a>'
115
+ );
116
+ }
117
+
118
+ function getTimestampSeconds(ts) {
119
+ if (!ts) return 0;
120
+ if (ts._seconds) return ts._seconds;
121
+ if (ts.seconds) return ts.seconds;
122
+ if (typeof ts === 'number') return ts;
123
+ return 0;
124
+ }
125
+
126
+ function formatTime(timestamp) {
127
+ if (!timestamp) return 'Unknown';
128
+ const date = timestamp.toDate ? timestamp.toDate() : new Date((timestamp._seconds || 0) * 1000);
129
+ const now = new Date();
130
+ const diff = now - date;
131
+
132
+ const minutes = Math.floor(diff / 60000);
133
+ const hours = Math.floor(diff / 3600000);
134
+ const days = Math.floor(diff / 86400000);
135
+
136
+ if (minutes < 60) return `${minutes}m ago`;
137
+ if (hours < 24) return `${hours}h ago`;
138
+ if (days < 7) return `${days}d ago`;
139
+
140
+ return date.toLocaleDateString();
141
+ }
142
+
143
+ // NOTE: these return HTML-escaped strings because patient-controlled values
144
+ // (name/email/message) are interpolated into innerHTML across the inbox.
145
+ function getPatientDisplay(thread) {
146
+ if (thread.patientName) return escapeHtml(thread.patientName);
147
+ if (thread.patientEmail) return escapeHtml(thread.patientEmail);
148
+ if (thread.clientName) return escapeHtml(thread.clientName);
149
+ if (thread.sessionId) return `Session ${escapeHtml(thread.sessionId.substring(0, 8))}`;
150
+ return 'Patient';
151
+ }
152
+
153
+ function getMessagePreview(thread) {
154
+ if (!thread.lastMessageText) return 'No messages yet';
155
+ const text = String(thread.lastMessageText);
156
+ return escapeHtml(text.substring(0, 60)) + (text.length > 60 ? '…' : '');
157
+ }
158
+
159
+ function getTriageBadgeHTML(category) {
160
+ if (!category) return '';
161
+ const labels = {
162
+ urgent: 'Urgent',
163
+ needs_review: 'Needs Review',
164
+ informational: 'Info',
165
+ resolved: 'Resolved'
166
+ };
167
+ return `<span class="triage-badge ${category}">${labels[category] || category}</span>`;
168
+ }
169
+
170
+ // ============================================
171
+ // FILTERING
172
+ // ============================================
173
+
174
+ let threadSearchQuery = '';
175
+
176
+ function filterThreads() {
177
+ return threads.filter(thread => {
178
+ if (currentFilter !== 'all') {
179
+ if (currentFilter === 'escalated' && thread.isEscalated !== true) return false;
180
+ if (currentFilter !== 'escalated' && thread.triageCategory !== currentFilter) return false;
181
+ }
182
+ if (threadSearchQuery) {
183
+ const q = threadSearchQuery;
184
+ const fields = [
185
+ thread.patientName, thread.clientName, thread.patientEmail,
186
+ thread.lastMessageText, thread.lastUserMessage, thread.currentTopic, thread.topic
187
+ ].filter(Boolean).map(f => String(f).toLowerCase());
188
+ return fields.some(f => f.includes(q));
189
+ }
190
+ return true;
191
+ });
192
+ }
193
+
194
+ // ============================================
195
+ // RENDER THREADS LIST
196
+ // ============================================
197
+
198
+ function renderThreadsList() {
199
+ const filtered = filterThreads();
200
+
201
+ if (filtered.length === 0) {
202
+ conversationsList.innerHTML = '<p style="color: #6b7280; text-align: center; margin-top: 2rem;">No conversations found</p>';
203
+ return;
204
+ }
205
+
206
+ conversationsList.innerHTML = '';
207
+ filtered.forEach(thread => {
208
+ const item = document.createElement('div');
209
+ item.className = 'conversation-item';
210
+ if (selectedThread && selectedThread.id === thread.id) {
211
+ item.classList.add('active');
212
+ }
213
+
214
+ const patientDisplay = getPatientDisplay(thread);
215
+ const preview = getMessagePreview(thread);
216
+ const time = formatTime(thread.lastMessageAt || thread.createdAt);
217
+
218
+ const unreadDot = thread.unreadByDoctor > 0
219
+ ? '<span style="display:inline-block;width:8px;height:8px;background:#4fb3a4;border-radius:50%;margin-right:0.5rem;"></span>'
220
+ : '';
221
+
222
+ const triageBadge = getTriageBadgeHTML(thread.triageCategory);
223
+ const sourceLabel = thread.patientSource === 'anonymous'
224
+ ? '<span style="font-size:0.65rem;color:#9ca3af;margin-left:0.25rem;">(anonymous)</span>'
225
+ : '';
226
+
227
+ item.innerHTML = `
228
+ <div class="conversation-header">
229
+ <div class="conversation-client">${unreadDot}${patientDisplay}${sourceLabel}</div>
230
+ <div class="conversation-time">${time}</div>
231
+ </div>
232
+ <div class="conversation-preview">${preview}</div>
233
+ <div style="margin-top: 0.5rem; display: flex; gap: 0.25rem; flex-wrap: wrap;">
234
+ ${triageBadge}
235
+ ${thread.isEscalated ? '<span class="conversation-badge escalated">Escalated</span>' : ''}
236
+ ${thread.aiDraftStatus === 'pending' ? '<span class="conversation-badge" style="background:#fffbeb;color:#92400e;border:1px solid #fde68a;">Draft Ready</span>' : ''}
237
+ ${thread.status === 'resolved' ? '<span class="conversation-badge" style="background:#d1fae5;color:#065f46;border:1px solid #6ee7b7;">Resolved</span>' : ''}
238
+ ${thread.doctorOverride ? '<span class="conversation-badge" style="background:#eff6ff;color:#2563eb;border:1px solid #93c5fd;">Manual</span>' : ''}
239
+ </div>
240
+ `;
241
+
242
+ item.tabIndex = 0;
243
+ item.setAttribute('role', 'button');
244
+ item.setAttribute('aria-label', `Conversation with ${patientDisplay}`);
245
+ const selectThread = () => {
246
+ selectedThread = thread;
247
+ renderThreadsList();
248
+ renderThreadDetail(thread);
249
+ };
250
+ item.addEventListener('click', selectThread);
251
+ item.addEventListener('keydown', (e) => {
252
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectThread(); }
253
+ });
254
+
255
+ conversationsList.appendChild(item);
256
+ });
257
+ }
258
+
259
+ // ============================================
260
+ // RENDER THREAD DETAIL
261
+ // ============================================
262
+
263
+ async function renderThreadDetail(thread) {
264
+ conversationDetail.innerHTML = '<div style="text-align: center; padding: 2rem;">Loading...</div>';
265
+
266
+ try {
267
+ let fullThread, messages;
268
+
269
+ if (thread._source === 'conversations') {
270
+ // Legacy conversation endpoint
271
+ const res = await fetchWithAuth(`/api/conversation/${thread.id}`);
272
+ if (!res.ok) throw new Error('Unable to load conversation details');
273
+ const data = await res.json();
274
+ fullThread = {
275
+ ...thread,
276
+ patientName: data.clientName || thread.patientName,
277
+ patientEmail: data.clientEmail || thread.patientEmail,
278
+ clientPhone: data.clientPhone || thread.clientPhone,
279
+ isEscalated: data.isEscalated || false,
280
+ currentTopic: data.currentTopic,
281
+ createdAt: data.createdAt
282
+ };
283
+ messages = (data.messages || []).map(m => ({
284
+ ...m,
285
+ message: m.message || m.content
286
+ }));
287
+ } else {
288
+ const res = await fetchWithAuth(`/api/doctor/thread/${thread.id}`);
289
+ if (!res.ok) throw new Error('Unable to load thread details');
290
+ const data = await res.json();
291
+ fullThread = data.thread;
292
+ messages = data.messages || [];
293
+ }
294
+ const patientDisplay = getPatientDisplay(fullThread);
295
+
296
+ let detailHTML = `
297
+ <div class="detail-header">
298
+ <div style="display: flex; justify-content: space-between; align-items: start;">
299
+ <div class="detail-title">${patientDisplay}</div>
300
+ ${getTriageBadgeHTML(fullThread.triageCategory)}
301
+ </div>
302
+ <div class="detail-meta">
303
+ <div class="detail-meta-item">
304
+ <span class="detail-meta-label">${messages.length} msgs</span>
305
+ </div>
306
+ <div class="detail-meta-item">
307
+ <span class="detail-meta-value">${formatTime(fullThread.createdAt)}</span>
308
+ </div>
309
+ ${fullThread.currentTopic ? `
310
+ <div class="detail-meta-item">
311
+ <span class="detail-meta-value">${(fullThread.currentTopic || '').replace(/_/g, ' ')}</span>
312
+ </div>
313
+ ` : ''}
314
+ ${fullThread.isEscalated ? `
315
+ <div class="detail-meta-item">
316
+ <span class="detail-meta-value" style="color: #dc2626;">Escalated</span>
317
+ </div>
318
+ ` : ''}
319
+ </div>
320
+
321
+ ${fullThread.patientEmail || fullThread.patientName || fullThread.clientPhone ? `
322
+ <div style="margin-top: 0.75rem; padding: 0.75rem; background: #e6f7f4; border-radius: 6px; border-left: 3px solid #297B56; font-size: 0.85rem;">
323
+ ${fullThread.patientName ? `<div style="font-weight: 600; margin-bottom: 0.25rem;">${escapeHtml(fullThread.patientName)}</div>` : ''}
324
+ ${fullThread.patientEmail ? `<div><a href="mailto:${encodeURIComponent(fullThread.patientEmail)}" style="color: #297B56;">${escapeHtml(fullThread.patientEmail)}</a></div>` : ''}
325
+ ${fullThread.clientPhone ? `<div><a href="tel:${encodeURIComponent(fullThread.clientPhone)}" style="color: #297B56;">${escapeHtml(fullThread.clientPhone)}</a></div>` : ''}
326
+ </div>
327
+ ` : ''}
328
+
329
+ <!-- Control Panel (only for unified threads, not legacy conversations) -->
330
+ ${thread._source !== 'conversations' ? `
331
+ <div style="margin-top: 1rem; display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;">
332
+ <button id="toggleOverrideBtn" class="filter-btn ${fullThread.doctorOverride ? 'active' : ''}">
333
+ ${fullThread.doctorOverride ? 'Enable AI' : 'Take Over (Manual)'}
334
+ </button>
335
+ ${fullThread.isEscalated ? '<button id="clearEscalationBtn" class="filter-btn">Clear Escalation</button>' : ''}
336
+ ${fullThread.status !== 'resolved' ? '<button id="resolveThreadBtn" class="filter-btn" style="background:#d1fae5;color:#065f46;border-color:#6ee7b7;">Resolve</button>' : '<button id="reopenThreadBtn" class="filter-btn">Reopen</button>'}
337
+ <button id="archiveThreadBtn" class="filter-btn">Archive</button>
338
+ <button id="exportThreadBtn" class="filter-btn" title="Export transcript">Export</button>
339
+ <select id="triageOverride" class="triage-select">
340
+ <option value="">Set Triage...</option>
341
+ <option value="urgent" ${fullThread.triageCategory === 'urgent' ? 'selected' : ''}>Urgent</option>
342
+ <option value="needs_review" ${fullThread.triageCategory === 'needs_review' ? 'selected' : ''}>Needs Review</option>
343
+ <option value="informational" ${fullThread.triageCategory === 'informational' ? 'selected' : ''}>Informational</option>
344
+ <option value="resolved" ${fullThread.triageCategory === 'resolved' ? 'selected' : ''}>Resolved</option>
345
+ </select>
346
+ </div>
347
+ ` : ''}
348
+ </div>
349
+ `;
350
+
351
+ // AI Draft Panel (Component 3)
352
+ if (fullThread.aiDraftResponse && fullThread.aiDraftStatus === 'pending') {
353
+ detailHTML += `
354
+ <div class="ai-draft-panel">
355
+ <div class="ai-draft-header">AI Suggested Response</div>
356
+ <div class="ai-draft-content">${fullThread.aiDraftResponse}</div>
357
+ <div class="ai-draft-actions">
358
+ <button id="acceptDraftBtn" class="btn-accept">Accept &amp; Send</button>
359
+ <button id="editDraftBtn" class="btn-edit">Edit &amp; Send</button>
360
+ <button id="rejectDraftBtn" class="btn-reject">Dismiss</button>
361
+ <button id="regenerateDraftBtn" class="btn-regenerate">Regenerate</button>
362
+ </div>
363
+ </div>
364
+ `;
365
+ }
366
+
367
+ // Reply Box
368
+ detailHTML += `
369
+ <div style="margin-bottom: 1.5rem; padding: 1rem; background: #f9fafb; border-radius: 10px; border: 1px solid #e5e7eb;">
370
+ <div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.75rem; color: #297B56;">Reply to Patient</div>
371
+ <textarea id="replyTextarea" rows="3" placeholder="Type your reply..." class="reply-textarea" maxlength="5000" aria-label="Reply message"></textarea>
372
+ <div class="reply-toolbar" style="margin-top: 0.5rem;">
373
+ <span id="replyCharCount" style="font-size:0.75rem;color:#9ca3af;" aria-live="polite"></span>
374
+ <span id="replyStatus" class="reply-status" aria-live="assertive"></span>
375
+ <button id="sendReplyBtn" class="filter-btn active" style="background: #297B56; color: white; border-color: #297B56;">Send Reply</button>
376
+ </div>
377
+ </div>
378
+
379
+ <div class="messages-list">
380
+ `;
381
+
382
+ if (messages.length === 0) {
383
+ detailHTML += '<p style="text-align: center; color: #6b7280;">No messages yet</p>';
384
+ } else {
385
+ messages.forEach(msg => {
386
+ let role = 'assistant';
387
+ let label = 'AI Assistant';
388
+
389
+ if (msg.role === 'patient' || msg.role === 'user') {
390
+ role = 'user';
391
+ label = 'Patient';
392
+ } else if (msg.role === 'doctor' || msg.isDoctorManual) {
393
+ role = 'doctor';
394
+ label = 'You (Doctor)';
395
+ }
396
+
397
+ const rawText = msg.message || msg.content || '';
398
+ const safeText = escapeHtml(rawText);
399
+ const linkedText = linkifyUrls(safeText);
400
+
401
+ detailHTML += `
402
+ <div class="message-block">
403
+ <div class="message-label">${label} &middot; ${formatTime(msg.createdAt)}</div>
404
+ <div class="message-content ${role}" style="white-space:pre-wrap;">${linkedText}</div>
405
+ </div>
406
+ `;
407
+ });
408
+ }
409
+
410
+ detailHTML += '</div>';
411
+ conversationDetail.innerHTML = detailHTML;
412
+
413
+ // Wire up event listeners
414
+ wireDetailEventListeners(thread, fullThread);
415
+
416
+ } catch (err) {
417
+ console.error('Error loading thread detail:', err);
418
+ conversationDetail.innerHTML = '<div style="text-align: center; color: #dc2626; padding: 2rem;">Unable to load conversation details</div>';
419
+ }
420
+ }
421
+
422
+ // ============================================
423
+ // DETAIL EVENT LISTENERS
424
+ // ============================================
425
+
426
+ function wireDetailEventListeners(thread, fullThread) {
427
+ // Toggle AI override
428
+ const toggleBtn = document.getElementById('toggleOverrideBtn');
429
+ if (toggleBtn) {
430
+ toggleBtn.addEventListener('click', async () => {
431
+ const newOverride = !fullThread.doctorOverride;
432
+ await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
433
+ method: 'PATCH',
434
+ headers: { 'Content-Type': 'application/json' },
435
+ body: JSON.stringify({ doctorOverride: newOverride })
436
+ });
437
+ updateThreadLocally(thread.id, { doctorOverride: newOverride });
438
+ renderThreadsList();
439
+ renderThreadDetail(thread);
440
+ });
441
+ }
442
+
443
+ // Clear escalation
444
+ const clearBtn = document.getElementById('clearEscalationBtn');
445
+ if (clearBtn) {
446
+ clearBtn.addEventListener('click', async () => {
447
+ await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
448
+ method: 'PATCH',
449
+ headers: { 'Content-Type': 'application/json' },
450
+ body: JSON.stringify({ isEscalated: false })
451
+ });
452
+ updateThreadLocally(thread.id, { isEscalated: false });
453
+ renderThreadsList();
454
+ renderThreadDetail(thread);
455
+ });
456
+ }
457
+
458
+ // Archive
459
+ const archiveBtn = document.getElementById('archiveThreadBtn');
460
+ if (archiveBtn) {
461
+ archiveBtn.addEventListener('click', async () => {
462
+ if (!confirm('Archive this conversation?')) return;
463
+ await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
464
+ method: 'PATCH',
465
+ headers: { 'Content-Type': 'application/json' },
466
+ body: JSON.stringify({ status: 'archived' })
467
+ });
468
+ threads = threads.filter(t => t.id !== thread.id);
469
+ selectedThread = null;
470
+ renderThreadsList();
471
+ conversationDetail.innerHTML = '<div class="empty-state"><div class="empty-state-icon">&#128230;</div><h3>Archived</h3><p>Select another conversation</p></div>';
472
+ });
473
+ }
474
+
475
+ const resolveBtn = document.getElementById('resolveThreadBtn');
476
+ if (resolveBtn) {
477
+ resolveBtn.addEventListener('click', async () => {
478
+ await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
479
+ method: 'PATCH',
480
+ headers: { 'Content-Type': 'application/json' },
481
+ body: JSON.stringify({ status: 'resolved' })
482
+ });
483
+ updateThreadLocally(thread.id, { status: 'resolved' });
484
+ renderThreadsList();
485
+ renderThreadDetail(thread);
486
+ });
487
+ }
488
+
489
+ const reopenBtn = document.getElementById('reopenThreadBtn');
490
+ if (reopenBtn) {
491
+ reopenBtn.addEventListener('click', async () => {
492
+ await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
493
+ method: 'PATCH',
494
+ headers: { 'Content-Type': 'application/json' },
495
+ body: JSON.stringify({ status: 'active' })
496
+ });
497
+ updateThreadLocally(thread.id, { status: 'active' });
498
+ renderThreadsList();
499
+ renderThreadDetail(thread);
500
+ });
501
+ }
502
+
503
+ // Export transcript
504
+ const exportBtn = document.getElementById('exportThreadBtn');
505
+ if (exportBtn) {
506
+ exportBtn.addEventListener('click', async () => {
507
+ try {
508
+ let msgs;
509
+ if (thread._source === 'conversations') {
510
+ const res = await fetchWithAuth(`/api/conversation/${thread.id}`);
511
+ const data = await res.json();
512
+ msgs = data.messages || [];
513
+ } else {
514
+ const res = await fetchWithAuth(`/api/doctor/thread/${thread.id}`);
515
+ const data = await res.json();
516
+ msgs = data.messages || [];
517
+ }
518
+ const lines = msgs.map(m => {
519
+ const role = m.role === 'patient' || m.role === 'user' ? 'Patient' :
520
+ m.role === 'doctor' ? 'Doctor' : 'AI';
521
+ const text = m.message || m.content || '';
522
+ const time = m.createdAt ? new Date(m.createdAt._seconds * 1000).toLocaleString() : '';
523
+ return `[${time}] ${role}: ${text}`;
524
+ });
525
+ const blob = new Blob([lines.join('\n\n')], { type: 'text/plain' });
526
+ const url = URL.createObjectURL(blob);
527
+ const a = document.createElement('a');
528
+ a.href = url;
529
+ a.download = `transcript-${thread.id}.txt`;
530
+ document.body.appendChild(a);
531
+ a.click();
532
+ document.body.removeChild(a);
533
+ URL.revokeObjectURL(url);
534
+ } catch (err) {
535
+ console.error('Export failed:', err);
536
+ }
537
+ });
538
+ }
539
+
540
+ // Triage override
541
+ const triageSelect = document.getElementById('triageOverride');
542
+ if (triageSelect) {
543
+ triageSelect.addEventListener('change', async () => {
544
+ const category = triageSelect.value;
545
+ if (!category) return;
546
+ await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
547
+ method: 'PATCH',
548
+ headers: { 'Content-Type': 'application/json' },
549
+ body: JSON.stringify({ triageCategory: category })
550
+ });
551
+ updateThreadLocally(thread.id, { triageCategory: category });
552
+ renderThreadsList();
553
+ });
554
+ }
555
+
556
+ // Send reply
557
+ const sendBtn = document.getElementById('sendReplyBtn');
558
+ const replyTextarea = document.getElementById('replyTextarea');
559
+ const replyStatus = document.getElementById('replyStatus');
560
+ const replyCharCount = document.getElementById('replyCharCount');
561
+ const REPLY_MAX = 5000;
562
+
563
+ if (sendBtn && replyTextarea) {
564
+ replyTextarea.addEventListener('input', () => {
565
+ const len = replyTextarea.value.length;
566
+ if (replyCharCount && len > REPLY_MAX * 0.7) {
567
+ replyCharCount.textContent = `${len}/${REPLY_MAX}`;
568
+ replyCharCount.style.color = len > REPLY_MAX * 0.9 ? '#dc2626' : '#9ca3af';
569
+ } else if (replyCharCount) {
570
+ replyCharCount.textContent = '';
571
+ }
572
+ });
573
+
574
+ sendBtn.addEventListener('click', async () => {
575
+ const message = replyTextarea.value.trim();
576
+ if (!message) return;
577
+ if (message.length > REPLY_MAX) {
578
+ replyStatus.textContent = 'Message too long';
579
+ return;
580
+ }
581
+
582
+ sendBtn.disabled = true;
583
+ replyStatus.textContent = 'Sending...';
584
+
585
+ try {
586
+ if (thread._source === 'conversations') {
587
+ await fetchWithAuth(`/api/conversation/${thread.id}/reply`, {
588
+ method: 'POST',
589
+ headers: { 'Content-Type': 'application/json' },
590
+ body: JSON.stringify({ message })
591
+ });
592
+ } else {
593
+ await fetchWithAuth('/api/doctor/message', {
594
+ method: 'POST',
595
+ headers: { 'Content-Type': 'application/json' },
596
+ body: JSON.stringify({ threadId: thread.id, message, override: true })
597
+ });
598
+ }
599
+ replyTextarea.value = '';
600
+ if (replyCharCount) replyCharCount.textContent = '';
601
+ replyStatus.textContent = 'Sent!';
602
+ setTimeout(() => { replyStatus.textContent = ''; }, 2000);
603
+ await renderThreadDetail(thread);
604
+ await loadThreads();
605
+ } catch (err) {
606
+ replyStatus.textContent = 'Failed to send. Try again.';
607
+ replyTextarea.focus();
608
+ } finally {
609
+ sendBtn.disabled = false;
610
+ }
611
+ });
612
+ }
613
+
614
+ // AI Draft actions (Component 3)
615
+ const acceptBtn = document.getElementById('acceptDraftBtn');
616
+ const editBtn = document.getElementById('editDraftBtn');
617
+ const rejectBtn = document.getElementById('rejectDraftBtn');
618
+ const regenerateBtn = document.getElementById('regenerateDraftBtn');
619
+
620
+ if (acceptBtn) {
621
+ acceptBtn.addEventListener('click', async () => {
622
+ acceptBtn.disabled = true;
623
+ acceptBtn.textContent = 'Sending...';
624
+ try {
625
+ await fetchWithAuth('/api/doctor/draft/accept', {
626
+ method: 'POST',
627
+ headers: { 'Content-Type': 'application/json' },
628
+ body: JSON.stringify({ threadId: thread.id })
629
+ });
630
+ await renderThreadDetail(thread);
631
+ await loadThreads();
632
+ } catch (err) {
633
+ alert('Failed to accept draft');
634
+ acceptBtn.disabled = false;
635
+ acceptBtn.textContent = 'Accept & Send';
636
+ }
637
+ });
638
+ }
639
+
640
+ if (editBtn) {
641
+ editBtn.addEventListener('click', () => {
642
+ // Pre-fill reply textarea with draft content
643
+ if (replyTextarea) {
644
+ replyTextarea.value = fullThread.aiDraftResponse || '';
645
+ replyTextarea.focus();
646
+ replyTextarea.scrollIntoView({ behavior: 'smooth' });
647
+ }
648
+ // Reject the draft since doctor is editing
649
+ fetchWithAuth('/api/doctor/draft/reject', {
650
+ method: 'POST',
651
+ headers: { 'Content-Type': 'application/json' },
652
+ body: JSON.stringify({ threadId: thread.id })
653
+ }).catch(() => {});
654
+ });
655
+ }
656
+
657
+ if (rejectBtn) {
658
+ rejectBtn.addEventListener('click', async () => {
659
+ await fetchWithAuth('/api/doctor/draft/reject', {
660
+ method: 'POST',
661
+ headers: { 'Content-Type': 'application/json' },
662
+ body: JSON.stringify({ threadId: thread.id })
663
+ });
664
+ updateThreadLocally(thread.id, { aiDraftStatus: 'rejected', aiDraftResponse: null });
665
+ renderThreadDetail(thread);
666
+ });
667
+ }
668
+
669
+ if (regenerateBtn) {
670
+ regenerateBtn.addEventListener('click', async () => {
671
+ regenerateBtn.disabled = true;
672
+ regenerateBtn.textContent = 'Generating...';
673
+ try {
674
+ const res = await fetchWithAuth('/api/doctor/draft/regenerate', {
675
+ method: 'POST',
676
+ headers: { 'Content-Type': 'application/json' },
677
+ body: JSON.stringify({ threadId: thread.id })
678
+ });
679
+ const data = await res.json();
680
+ if (data.draft) {
681
+ updateThreadLocally(thread.id, { aiDraftResponse: data.draft, aiDraftStatus: 'pending' });
682
+ }
683
+ await renderThreadDetail(thread);
684
+ } catch (err) {
685
+ alert('Failed to regenerate draft');
686
+ regenerateBtn.disabled = false;
687
+ regenerateBtn.textContent = 'Regenerate';
688
+ }
689
+ });
690
+ }
691
+ }
692
+
693
+ function updateThreadLocally(threadId, updates) {
694
+ const idx = threads.findIndex(t => t.id === threadId);
695
+ if (idx >= 0) {
696
+ Object.assign(threads[idx], updates);
697
+ }
698
+ if (selectedThread && selectedThread.id === threadId) {
699
+ Object.assign(selectedThread, updates);
700
+ }
701
+ }
702
+
703
+ // ============================================
704
+ // NOTIFICATIONS (Component 4)
705
+ // ============================================
706
+
707
+ async function loadNotifications() {
708
+ try {
709
+ const res = await fetchWithAuth('/api/doctor/notifications?limit=20');
710
+ if (!res.ok) return;
711
+ const data = await res.json();
712
+
713
+ const countEl = document.getElementById('notificationCount');
714
+ const listEl = document.getElementById('notificationList');
715
+
716
+ if (data.unreadCount > 0) {
717
+ countEl.textContent = data.unreadCount;
718
+ countEl.style.display = 'flex';
719
+ } else {
720
+ countEl.style.display = 'none';
721
+ }
722
+
723
+ if (!data.notifications || data.notifications.length === 0) {
724
+ listEl.innerHTML = '<div style="padding: 1rem; text-align: center; color: #6b7280; font-size: 0.85rem;">No notifications</div>';
725
+ return;
726
+ }
727
+
728
+ listEl.innerHTML = data.notifications.map(n => `
729
+ <div class="notification-item ${n.read ? '' : 'unread'}" data-thread-id="${n.threadId || ''}">
730
+ <div class="notification-item-title">${escapeHtml(n.title || '')}</div>
731
+ <div class="notification-item-body">${escapeHtml(n.body || '')}</div>
732
+ </div>
733
+ `).join('');
734
+
735
+ // Click on notification to jump to thread
736
+ listEl.querySelectorAll('.notification-item').forEach(el => {
737
+ el.addEventListener('click', () => {
738
+ const threadId = el.dataset.threadId;
739
+ if (threadId) {
740
+ const thread = threads.find(t => t.id === threadId);
741
+ if (thread) {
742
+ selectedThread = thread;
743
+ renderThreadsList();
744
+ renderThreadDetail(thread);
745
+ }
746
+ }
747
+ document.getElementById('notificationDropdown').classList.remove('open');
748
+ });
749
+ });
750
+ } catch (err) {
751
+ console.error('Error loading notifications:', err);
752
+ }
753
+ }
754
+
755
+ // Toggle notification dropdown
756
+ document.getElementById('notificationBell').addEventListener('click', (e) => {
757
+ e.stopPropagation();
758
+ const dropdown = document.getElementById('notificationDropdown');
759
+ dropdown.classList.toggle('open');
760
+ if (dropdown.classList.contains('open')) {
761
+ loadNotifications();
762
+ }
763
+ });
764
+
765
+ // Close dropdown when clicking elsewhere
766
+ document.addEventListener('click', () => {
767
+ document.getElementById('notificationDropdown').classList.remove('open');
768
+ });
769
+
770
+ // Mark all as read
771
+ document.getElementById('markAllReadBtn').addEventListener('click', async (e) => {
772
+ e.stopPropagation();
773
+ await fetchWithAuth('/api/doctor/notifications/read', {
774
+ method: 'POST',
775
+ headers: { 'Content-Type': 'application/json' },
776
+ body: JSON.stringify({})
777
+ });
778
+ document.getElementById('notificationCount').style.display = 'none';
779
+ loadNotifications();
780
+ });
781
+
782
+ // ============================================
783
+ // FILTER BUTTONS
784
+ // ============================================
785
+
786
+ document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => {
787
+ btn.addEventListener('click', () => {
788
+ document.querySelectorAll('.filter-btn[data-filter]').forEach(b => b.classList.remove('active'));
789
+ btn.classList.add('active');
790
+ currentFilter = btn.dataset.filter;
791
+ renderThreadsList();
792
+ });
793
+ });
794
+
795
+ const threadSearchInput = document.getElementById('threadSearchInput');
796
+ if (threadSearchInput) {
797
+ let searchTimer;
798
+ threadSearchInput.addEventListener('input', () => {
799
+ clearTimeout(searchTimer);
800
+ searchTimer = setTimeout(() => {
801
+ threadSearchQuery = threadSearchInput.value.trim().toLowerCase();
802
+ renderThreadsList();
803
+ }, 250);
804
+ });
805
+ }
806
+
807
+ // ============================================
808
+ // SIGN OUT
809
+ // ============================================
810
+
811
+ signOutBtn.addEventListener('click', async () => {
812
+ await firebase.auth().signOut();
813
+ window.location.href = '/app';
814
+ });
815
+
816
+ // ============================================
817
+ // INITIALIZE
818
+ // ============================================
819
+
820
+ firebase.auth().onAuthStateChanged(async (user) => {
821
+ if (!user) {
822
+ authRequired.classList.remove('hidden');
823
+ inboxContainer.classList.add('hidden');
824
+ return;
825
+ }
826
+
827
+ authRequired.classList.add('hidden');
828
+ inboxContainer.classList.remove('hidden');
829
+
830
+ currentProvider = await loadProvider();
831
+ if (!currentProvider) {
832
+ authRequired.classList.remove('hidden');
833
+ inboxContainer.classList.add('hidden');
834
+ return;
835
+ }
836
+
837
+ await loadThreads();
838
+ await loadNotifications();
839
+
840
+ // Auto-refresh every 30 seconds
841
+ setInterval(loadThreads, 30000);
842
+ setInterval(loadNotifications, 60000);
843
+ });