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
package/server.js ADDED
@@ -0,0 +1,3864 @@
1
+ // Load environment variables FIRST
2
+ require('dotenv').config();
3
+
4
+ const express = require('express');
5
+ const cors = require('cors');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+ const QRCode = require('qrcode');
9
+
10
+ // Shared Firebase Admin initialization
11
+ const { admin, initializeFirebase } = require('./functions/src/firebase-init');
12
+
13
+ // In test mode, firebase-admin is mocked by Jest — skip real credential loading
14
+ if (process.env.NODE_ENV !== 'test') {
15
+ try {
16
+ const credential = process.env.GOOGLE_APPLICATION_CREDENTIALS
17
+ ? admin.credential.cert(require(process.env.GOOGLE_APPLICATION_CREDENTIALS))
18
+ : undefined;
19
+ initializeFirebase({ credential });
20
+ console.log('Firebase Admin initialized');
21
+ } catch (err) {
22
+ console.error('Firebase Admin initialization failed:', err.message);
23
+ console.log('\nTo fix: download a service account key from Firebase Console');
24
+ console.log('and set GOOGLE_APPLICATION_CREDENTIALS=./serviceAccountKey.json in .env\n');
25
+ process.exit(1);
26
+ }
27
+ }
28
+
29
+ // Import modules from functions directory
30
+ const { TOPICS, TOPIC_KEYS, inferTopic } = require('./functions/src/topics');
31
+ const {
32
+ analyzeInput,
33
+ analyzeOutput,
34
+ refusalMessage,
35
+ escalationMessage,
36
+ redactText
37
+ } = require('./functions/src/safety');
38
+ const { generateResponse } = require('./functions/src/llm');
39
+ const { incrementAnalytics, getMonthKey } = require('./functions/src/analytics');
40
+ const { checkMonthlyLimit } = require('./functions/src/limits');
41
+ const { verifyAuthHeader } = require('./functions/src/auth');
42
+ const {
43
+ createConnectAccount,
44
+ createAccountLink,
45
+ refreshAccountStatus,
46
+ setProviderPricing,
47
+ createCheckoutSession,
48
+ createPaymentIntent,
49
+ createSubscription,
50
+ cancelSubscription,
51
+ checkPaymentAccess,
52
+ getRevenueAnalytics,
53
+ handleStripeWebhook
54
+ } = require('./functions/src/payment');
55
+ const { stripe, hasStripeSecretKey } = require('./functions/src/stripe-config');
56
+ const { handlePatientAPI } = require('./functions/src/patient-api');
57
+
58
+ // Audit logging (HIPAA compliance)
59
+ const { logAuditEvent } = require('./functions/src/audit');
60
+
61
+ // Organizations & RBAC (multi-provider clinics)
62
+ const orgs = require('./functions/src/orgs');
63
+
64
+ // Centralized input validation
65
+ const { validate } = require('./functions/src/validate');
66
+
67
+ // Phase 2: Doctor Async Response Pipeline modules
68
+ const { classifyConversation } = require('./functions/src/triage');
69
+ const { generateDoctorDraft } = require('./functions/src/drafts');
70
+ const {
71
+ notifyEscalation,
72
+ notifyDraftReady,
73
+ getNotifications,
74
+ getUnreadCount,
75
+ markNotificationRead,
76
+ markAllNotificationsRead,
77
+ generateDailyDigest
78
+ } = require('./functions/src/notifications');
79
+ const {
80
+ captureDoctorExample,
81
+ retrieveRelevantExamples,
82
+ getFeedbackStats
83
+ } = require('./functions/src/feedback');
84
+
85
+ const app = express();
86
+ // Behind Cloud Run / a proxy, honor X-Forwarded-For so req.ip is the real
87
+ // client (rate limiting keys on it) rather than the proxy address.
88
+ app.set('trust proxy', true);
89
+ const PORT = process.env.PORT || 8080;
90
+
91
+ // Middleware
92
+
93
+ // CORS — restrict to known origins; defaults to same-origin only
94
+ const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
95
+ ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
96
+ : [];
97
+ app.use(cors(ALLOWED_ORIGINS.length > 0
98
+ ? { origin: ALLOWED_ORIGINS }
99
+ : { origin: false }
100
+ ));
101
+
102
+ // Security headers
103
+ app.use((req, res, next) => {
104
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
105
+ res.setHeader('X-Content-Type-Options', 'nosniff');
106
+ res.setHeader('X-Frame-Options', 'DENY');
107
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
108
+ res.setHeader('X-XSS-Protection', '1; mode=block');
109
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
110
+ res.setHeader('Content-Security-Policy', [
111
+ "default-src 'self'",
112
+ "script-src 'self' https://js.stripe.com https://www.gstatic.com",
113
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
114
+ "font-src 'self' https://fonts.gstatic.com",
115
+ "img-src 'self' data: https:",
116
+ "frame-src https://js.stripe.com",
117
+ "connect-src 'self' https://api.stripe.com https://firestore.googleapis.com https://identitytoolkit.googleapis.com https://securetoken.googleapis.com",
118
+ "object-src 'none'",
119
+ "base-uri 'self'"
120
+ ].join('; '));
121
+ next();
122
+ });
123
+
124
+ // Request correlation id — echoed back so client errors can be traced to a
125
+ // specific server log line. Honors an inbound X-Request-Id if present.
126
+ app.use((req, res, next) => {
127
+ const inbound = req.headers['x-request-id'];
128
+ req.requestId = (typeof inbound === 'string' && inbound.length <= 64 && /^[\w-]+$/.test(inbound))
129
+ ? inbound
130
+ : crypto.randomBytes(8).toString('hex');
131
+ res.setHeader('X-Request-Id', req.requestId);
132
+ next();
133
+ });
134
+
135
+ app.use((req, res, next) => {
136
+ if (req.path.startsWith('/api/')) {
137
+ const start = Date.now();
138
+ res.on('finish', () => {
139
+ const duration = Date.now() - start;
140
+ if (duration > 2000) {
141
+ console.warn(`[SLOW] [${req.requestId}] ${req.method} ${req.path} took ${duration}ms`);
142
+ }
143
+ });
144
+ }
145
+ next();
146
+ });
147
+
148
+ // Lightweight in-memory rate limiter (self-contained; accepts both the
149
+ // { maxRequests } and express-rate-limit-style { max, message } option shapes
150
+ // so all call sites work without pulling in an external dependency).
151
+ const rateLimitStore = new Map();
152
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
153
+ const RATE_LIMIT_CLEANUP_INTERVAL = 5 * 60 * 1000; // cleanup every 5 min
154
+
155
+ function rateLimit({ maxRequests, max, windowMs = RATE_LIMIT_WINDOW_MS, message } = {}) {
156
+ const limit = typeof maxRequests === 'number' ? maxRequests : (typeof max === 'number' ? max : 30);
157
+ const body = message || { error: 'Too many requests. Please wait and try again.' };
158
+ return (req, res, next) => {
159
+ const ip = req.ip || req.connection.remoteAddress || 'unknown';
160
+ const key = `${ip}:${req.path}`;
161
+ const now = Date.now();
162
+ let entry = rateLimitStore.get(key);
163
+
164
+ if (!entry || now - entry.windowStart > windowMs) {
165
+ entry = { windowStart: now, count: 0 };
166
+ }
167
+
168
+ entry.count++;
169
+ rateLimitStore.set(key, entry);
170
+
171
+ if (entry.count > limit) {
172
+ return res.status(429).json(body);
173
+ }
174
+
175
+ res.setHeader('X-RateLimit-Limit', String(limit));
176
+ res.setHeader('X-RateLimit-Remaining', String(Math.max(0, limit - entry.count)));
177
+ next();
178
+ };
179
+ }
180
+
181
+ // Periodic cleanup of expired rate limit entries
182
+ if (process.env.NODE_ENV !== 'test') {
183
+ setInterval(() => {
184
+ const now = Date.now();
185
+ for (const [key, entry] of rateLimitStore) {
186
+ if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
187
+ rateLimitStore.delete(key);
188
+ }
189
+ }
190
+ }, RATE_LIMIT_CLEANUP_INTERVAL);
191
+ }
192
+
193
+ // Rate limiting — public endpoints (config from origin/main, backed by the limiter above)
194
+ const chatLimiter = rateLimit({
195
+ windowMs: 60 * 1000,
196
+ max: 12,
197
+ message: { error: 'Too many requests. Please wait a moment before trying again.' }
198
+ });
199
+ const publicLimiter = rateLimit({
200
+ windowMs: 60 * 1000,
201
+ max: 30,
202
+ message: { error: 'Too many requests. Please try again later.' }
203
+ });
204
+ app.use('/api/chat', chatLimiter);
205
+ app.use('/api/waitlist', publicLimiter);
206
+ app.use('/api/satisfaction', publicLimiter);
207
+ app.use('/api/payment/create-checkout-session', publicLimiter);
208
+ app.use('/api/payment/create-intent', publicLimiter);
209
+
210
+ // Body parsing — capture the raw body for Stripe webhook signature verification
211
+ // and cap request size.
212
+ app.use(express.json({
213
+ limit: '100kb',
214
+ verify: (req, res, buf) => {
215
+ if (req.originalUrl === '/api/payment/webhook') {
216
+ req.rawBody = buf;
217
+ }
218
+ }
219
+ }));
220
+ app.use(express.urlencoded({ extended: true, limit: '100kb' }));
221
+
222
+ if (typeof hasStripeSecretKey === 'function' && !hasStripeSecretKey()) {
223
+ console.warn('[Stripe] STRIPE_SECRET_KEY is not configured. Payment endpoints will fail until it is set.');
224
+ }
225
+
226
+ // Serve static files from public directory
227
+ app.use(express.static(path.join(__dirname, 'public')));
228
+
229
+ // Constants
230
+ const HANDLE_REGEX = /^[a-z0-9][a-z0-9-]{2,29}$/;
231
+ const RESERVED_HANDLES = new Set(['app', 'api', 'admin', 'static', 'public', 'functions', 'health']);
232
+ const LAUNCH_DATE = new Date('2026-04-01T00:00:00Z');
233
+ // Waitlist gating can be disabled for open self-serve / PLG growth.
234
+ // Set WAITLIST_ENABLED=false to allow instant signups regardless of launch date.
235
+ const WAITLIST_ENABLED = process.env.WAITLIST_ENABLED !== 'false';
236
+
237
+ // Platform-admin allowlist (comma-separated Firebase UIDs). Fail-closed: if
238
+ // unset, no one passes the platform-admin gate. Used for platform-wide views.
239
+ const ADMIN_UIDS = new Set(
240
+ String(process.env.ADMIN_UIDS || '').split(',').map(s => s.trim()).filter(Boolean)
241
+ );
242
+ function isPlatformAdmin(uid) {
243
+ return ADMIN_UIDS.has(uid);
244
+ }
245
+
246
+ // Helper functions
247
+ function nowTimestamp() {
248
+ return admin.firestore.FieldValue.serverTimestamp();
249
+ }
250
+
251
+ function sanitizeJsonObject(obj) {
252
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return obj;
253
+ const clean = {};
254
+ for (const key of Object.keys(obj)) {
255
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
256
+ const val = obj[key];
257
+ clean[key] = (typeof val === 'object' && val !== null) ? sanitizeJsonObject(val) : val;
258
+ }
259
+ return clean;
260
+ }
261
+
262
+ function normalizeHandle(handle) {
263
+ return String(handle || '')
264
+ .trim()
265
+ .toLowerCase()
266
+ .replace(/\s+/g, '-')
267
+ .replace(/[^a-z0-9-]/g, '');
268
+ }
269
+
270
+ // Standard error response — always carries the request-correlation id so a
271
+ // client-reported failure maps to a specific server log line.
272
+ function sendError(req, res, status, message, extra) {
273
+ return res.status(status).json({ error: message, requestId: req.requestId, ...(extra || {}) });
274
+ }
275
+
276
+ async function requireAuth(req, res) {
277
+ const decoded = await verifyAuthHeader(req.headers.authorization || '');
278
+ if (!decoded) {
279
+ sendError(req, res, 401, 'Unauthorized');
280
+ return null;
281
+ }
282
+ return decoded;
283
+ }
284
+
285
+ // Auth + provider-ownership guard. Returns { user, providerDoc } or null
286
+ // (having already sent 401/404). Centralizes the preamble that was repeated
287
+ // across ~35 endpoints so an ownership check can never be silently omitted.
288
+ async function requireProvider(req, res) {
289
+ const user = await requireAuth(req, res);
290
+ if (!user) return null;
291
+ const providerDoc = await findProviderByOwner(user.uid);
292
+ if (!providerDoc) {
293
+ sendError(req, res, 404, 'Provider not found');
294
+ return null;
295
+ }
296
+ return { user, providerDoc };
297
+ }
298
+
299
+ // Auth + org-membership guard, optionally enforcing an RBAC permission.
300
+ // Returns the membership object or null (having sent 401/403/404).
301
+ async function requireMembership(req, res, permission) {
302
+ const user = await requireAuth(req, res);
303
+ if (!user) return null;
304
+ const membership = await resolveMembership(user);
305
+ if (!membership) {
306
+ sendError(req, res, 404, 'No organization found');
307
+ return null;
308
+ }
309
+ if (permission && !orgs.can(membership.role, permission)) {
310
+ sendError(req, res, 403, 'You do not have permission to perform this action');
311
+ return null;
312
+ }
313
+ membership.user = user;
314
+ return membership;
315
+ }
316
+
317
+ async function findProviderByHandle(handle) {
318
+ const db = admin.firestore();
319
+ const snapshot = await db
320
+ .collection('providers')
321
+ .where('handle', '==', handle)
322
+ .limit(1)
323
+ .get();
324
+ if (snapshot.empty) return null;
325
+ return snapshot.docs[0];
326
+ }
327
+
328
+ async function findProviderByOwner(uid) {
329
+ if (!uid) {
330
+ throw new Error('User ID is required');
331
+ }
332
+ const db = admin.firestore();
333
+ const doc = await db.collection('providers').doc(uid).get();
334
+ if (!doc.exists) return null;
335
+ return doc;
336
+ }
337
+
338
+ /**
339
+ * Resolve the caller's organization membership.
340
+ *
341
+ * Lazily creates a "personal org" the first time an existing solo provider
342
+ * touches an org endpoint: orgId = providerId, the provider as sole `owner`.
343
+ * This keeps the entire solo flow non-breaking — nothing is created until a
344
+ * user actually engages with org features.
345
+ *
346
+ * Returns { orgId, org, role, providerId, member } or null if the user has not
347
+ * onboarded as a provider yet.
348
+ */
349
+ async function resolveMembership(user) {
350
+ const db = admin.firestore();
351
+ const membershipDoc = await db.collection('memberships').doc(user.uid).get();
352
+
353
+ if (membershipDoc.exists) {
354
+ const { orgId, role } = membershipDoc.data();
355
+ const [orgDoc, memberDoc] = await Promise.all([
356
+ db.collection('organizations').doc(orgId).get(),
357
+ db.collection('organizations').doc(orgId).collection('members').doc(user.uid).get()
358
+ ]);
359
+ if (!orgDoc.exists) return null;
360
+ const member = memberDoc.exists ? memberDoc.data() : null;
361
+ return {
362
+ orgId,
363
+ org: { id: orgId, ...orgDoc.data() },
364
+ role: (member && member.role) || role,
365
+ providerId: member && member.providerId ? member.providerId : null,
366
+ member
367
+ };
368
+ }
369
+
370
+ // No membership yet — lazily create a personal org if the user is a provider.
371
+ const providerDoc = await findProviderByOwner(user.uid);
372
+ if (!providerDoc) return null;
373
+
374
+ const orgId = providerDoc.id;
375
+ const provider = providerDoc.data();
376
+ const orgData = {
377
+ ownerUid: user.uid,
378
+ name: provider.displayName ? `${provider.displayName}'s Practice` : 'My Practice',
379
+ type: 'personal',
380
+ plan: provider.plan || 'free',
381
+ seats: 1,
382
+ createdAt: nowTimestamp(),
383
+ updatedAt: nowTimestamp()
384
+ };
385
+ const memberData = {
386
+ uid: user.uid,
387
+ email: user.email || null,
388
+ role: 'owner',
389
+ providerId: orgId,
390
+ status: 'active',
391
+ joinedAt: nowTimestamp()
392
+ };
393
+
394
+ await db.collection('organizations').doc(orgId).set(orgData, { merge: true });
395
+ await db.collection('organizations').doc(orgId).collection('members').doc(user.uid).set(memberData);
396
+ await db.collection('memberships').doc(user.uid).set({ orgId, role: 'owner' });
397
+
398
+ return { orgId, org: { id: orgId, ...orgData }, role: 'owner', providerId: orgId, member: memberData };
399
+ }
400
+
401
+ /** Count active (non-invited) members in an org. */
402
+ async function countActiveMembers(orgId) {
403
+ const db = admin.firestore();
404
+ const snap = await db.collection('organizations').doc(orgId).collection('members')
405
+ .where('status', '==', 'active').get();
406
+ return snap.size;
407
+ }
408
+
409
+ async function findConversation({ providerId, sessionId }) {
410
+ const db = admin.firestore();
411
+ const snapshot = await db
412
+ .collection('conversations')
413
+ .where('providerId', '==', providerId)
414
+ .where('sessionId', '==', sessionId)
415
+ .orderBy('createdAt', 'desc')
416
+ .limit(1)
417
+ .get();
418
+
419
+ if (snapshot.empty) return null;
420
+ return snapshot.docs[0];
421
+ }
422
+
423
+ function resolveTopic({ requestedTopic, enabledTopics, message }) {
424
+ if (requestedTopic && enabledTopics.includes(requestedTopic)) {
425
+ return requestedTopic;
426
+ }
427
+ const inferred = inferTopic(message);
428
+ if (enabledTopics.includes(inferred)) {
429
+ return inferred;
430
+ }
431
+ return enabledTopics[0] || TOPIC_KEYS[0];
432
+ }
433
+
434
+ function mapProviderPublic(provider) {
435
+ return {
436
+ handle: provider.handle,
437
+ displayName: provider.displayName,
438
+ specialty: provider.specialty,
439
+ country: provider.country,
440
+ locale: provider.locale,
441
+ photoUrl: provider.photoUrl || null,
442
+ logoUrl: provider.logoUrl || null,
443
+ brandPrimaryColor: provider.brandPrimaryColor || null,
444
+ brandSecondaryColor: provider.brandSecondaryColor || null,
445
+ welcomeMessage: provider.welcomeMessage || null,
446
+ enabledTopics: provider.enabledTopics || TOPIC_KEYS,
447
+ contactInfo: provider.contactInfo || null,
448
+ businessHours: provider.businessHours || null,
449
+ isPreview: provider.status === 'preview',
450
+ isVerified: provider.verificationStatus === 'verified'
451
+ };
452
+ }
453
+
454
+ function mapProviderPrivate(provider) {
455
+ return {
456
+ id: provider.id,
457
+ ownerUid: provider.ownerUid,
458
+ handle: provider.handle,
459
+ displayName: provider.displayName,
460
+ specialty: provider.specialty,
461
+ country: provider.country,
462
+ locale: provider.locale,
463
+ photoUrl: provider.photoUrl || null,
464
+ logoUrl: provider.logoUrl || null,
465
+ brandPrimaryColor: provider.brandPrimaryColor || null,
466
+ brandSecondaryColor: provider.brandSecondaryColor || null,
467
+ welcomeMessage: provider.welcomeMessage || null,
468
+ enabledTopics: provider.enabledTopics || TOPIC_KEYS,
469
+ contactInfo: provider.contactInfo || null,
470
+ businessHours: provider.businessHours || null,
471
+ status: provider.status,
472
+ plan: provider.plan,
473
+ monthlyChatLimit: provider.monthlyChatLimit,
474
+ monthlyChatCount: provider.monthlyChatCount || 0,
475
+ monthlyChatCountMonth: provider.monthlyChatCountMonth || null,
476
+ totalChatCount: provider.totalChatCount || 0,
477
+ totalEscalationCount: provider.totalEscalationCount || 0,
478
+ stripeAccountId: provider.stripeAccountId || null,
479
+ stripeOnboardingComplete: provider.stripeOnboardingComplete || false,
480
+ pricingModel: provider.pricingModel || null,
481
+ paymentEnabled: provider.paymentEnabled || false,
482
+ perChatPrice: provider.perChatPrice || null,
483
+ subscriptionPrice: provider.subscriptionPrice || null,
484
+ verificationStatus: provider.verificationStatus || 'pending'
485
+ };
486
+ }
487
+
488
+ function sanitizeTopics(topics) {
489
+ if (!Array.isArray(topics)) return null;
490
+ const filtered = topics.filter((topic) => TOPIC_KEYS.includes(topic));
491
+ return filtered.length ? filtered : null;
492
+ }
493
+
494
+ const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
495
+
496
+ function stripHtml(str) {
497
+ return String(str).replace(/<[^>]*>/g, '').trim();
498
+ }
499
+
500
+ function sanitizeContactInfo(ci) {
501
+ if (!ci || typeof ci !== 'object') return null;
502
+ const clean = {};
503
+ if (ci.phone) clean.phone = stripHtml(String(ci.phone)).replace(/[^0-9+\-() .ext]/gi, '').substring(0, 30);
504
+ if (ci.email) {
505
+ const email = String(ci.email).trim().toLowerCase();
506
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) clean.email = email;
507
+ }
508
+ if (ci.address) clean.address = stripHtml(ci.address).substring(0, 200);
509
+ return Object.keys(clean).length ? clean : null;
510
+ }
511
+
512
+ function sanitizeProviderUpdate(body) {
513
+ const update = {};
514
+ if (body.displayName !== undefined) {
515
+ const name = stripHtml(body.displayName).substring(0, 100);
516
+ if (name) update.displayName = name;
517
+ }
518
+ if (body.specialty !== undefined) {
519
+ update.specialty = stripHtml(body.specialty).substring(0, 100);
520
+ }
521
+ if (body.country !== undefined) update.country = stripHtml(body.country).substring(0, 60);
522
+ if (body.locale !== undefined) update.locale = String(body.locale).trim().substring(0, 10);
523
+ if (body.photoUrl !== undefined) update.photoUrl = body.photoUrl ? String(body.photoUrl).trim() : null;
524
+ if (body.logoUrl !== undefined) update.logoUrl = body.logoUrl ? String(body.logoUrl).trim() : null;
525
+ if (body.brandPrimaryColor !== undefined) {
526
+ const c = String(body.brandPrimaryColor).trim();
527
+ if (HEX_COLOR_REGEX.test(c)) update.brandPrimaryColor = c;
528
+ }
529
+ if (body.brandSecondaryColor !== undefined) {
530
+ const c = String(body.brandSecondaryColor).trim();
531
+ if (HEX_COLOR_REGEX.test(c)) update.brandSecondaryColor = c;
532
+ }
533
+ if (body.welcomeMessage !== undefined) update.welcomeMessage = stripHtml(body.welcomeMessage).substring(0, 500);
534
+ if (body.contactInfo !== undefined) {
535
+ update.contactInfo = body.contactInfo === null ? null : sanitizeContactInfo(body.contactInfo);
536
+ }
537
+ if (body.businessHours !== undefined && (body.businessHours === null || typeof body.businessHours === 'object')) {
538
+ update.businessHours = body.businessHours ? sanitizeJsonObject(body.businessHours) : null;
539
+ }
540
+
541
+ const topics = sanitizeTopics(body.enabledTopics);
542
+ if (topics) update.enabledTopics = topics;
543
+
544
+ if (body.status && ['draft', 'preview', 'published', 'suspended'].includes(body.status)) {
545
+ update.status = body.status;
546
+ }
547
+
548
+ return update;
549
+ }
550
+
551
+ // ============================================
552
+ // API ROUTES
553
+ // ============================================
554
+
555
+ // Health check
556
+ const serverStartTime = Date.now();
557
+ app.get('/health', async (req, res) => {
558
+ const health = {
559
+ ok: true,
560
+ timestamp: Date.now(),
561
+ uptime: Math.floor((Date.now() - serverStartTime) / 1000),
562
+ memory: Math.round(process.memoryUsage().rss / 1024 / 1024)
563
+ };
564
+
565
+ if (req.query.deep === 'true') {
566
+ try {
567
+ const db = admin.firestore();
568
+ await db.collection('providers').limit(1).get();
569
+ health.database = 'connected';
570
+ } catch {
571
+ health.database = 'error';
572
+ health.ok = false;
573
+ }
574
+ }
575
+
576
+ res.status(health.ok ? 200 : 503).json(health);
577
+ });
578
+
579
+ // Patient API routes
580
+ app.all('/api/patient/*', async (req, res) => {
581
+ try {
582
+ const authHeader = req.headers.authorization || '';
583
+ const user = await verifyAuthHeader(authHeader);
584
+
585
+ if (!user) {
586
+ return res.status(401).json({ error: 'Unauthorized' });
587
+ }
588
+
589
+ // Build a modified request object with the /api prefix stripped from path
590
+ // (req.path is a read-only getter in Express, so we override via Object.defineProperty)
591
+ const strippedPath = req.path.replace('/api', '');
592
+ Object.defineProperty(req, 'path', { value: strippedPath, writable: true, configurable: true });
593
+
594
+ await handlePatientAPI(req, res, user);
595
+ } catch (error) {
596
+ console.error('Patient API error:', error);
597
+ if (!res.headersSent) {
598
+ res.status(500).json({ error: 'Internal server error' });
599
+ }
600
+ }
601
+ });
602
+
603
+ // Analytics endpoint (admin only)
604
+ app.get('/api/admin/analytics', rateLimit({ maxRequests: 10, windowMs: 60000 }), async (req, res) => {
605
+ try {
606
+ const user = await requireAuth(req, res);
607
+ if (!user) return;
608
+
609
+ // Platform-wide analytics is admin-only (leaks every provider's data otherwise).
610
+ if (!isPlatformAdmin(user.uid)) {
611
+ return res.status(403).json({ error: 'Forbidden' });
612
+ }
613
+
614
+ const validPeriods = ['all', 'today', 'week', 'month'];
615
+ const timePeriod = validPeriods.includes(req.query.period) ? req.query.period : 'all';
616
+ const db = admin.firestore();
617
+
618
+ // Calculate date filter based on period
619
+ let dateFilter = null;
620
+ const now = new Date();
621
+
622
+ switch (timePeriod) {
623
+ case 'today':
624
+ dateFilter = new Date(now.getFullYear(), now.getMonth(), now.getDate());
625
+ break;
626
+ case 'week':
627
+ dateFilter = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
628
+ break;
629
+ case 'month':
630
+ dateFilter = new Date(now.getFullYear(), now.getMonth(), 1);
631
+ break;
632
+ case 'quarter':
633
+ const quarterStartMonth = Math.floor(now.getMonth() / 3) * 3;
634
+ dateFilter = new Date(now.getFullYear(), quarterStartMonth, 1);
635
+ break;
636
+ }
637
+
638
+ // Get providers (bounded to avoid loading an unbounded set into memory).
639
+ const providersSnapshot = await db.collection('providers').limit(5000).get();
640
+ const providers = [];
641
+
642
+ for (const doc of providersSnapshot.docs) {
643
+ const providerData = doc.data();
644
+ const providerId = doc.id;
645
+
646
+ // Get conversation stats for this provider
647
+ let conversationsQuery = db
648
+ .collection('conversations')
649
+ .where('providerId', '==', providerId);
650
+
651
+ if (dateFilter) {
652
+ conversationsQuery = conversationsQuery.where('createdAt', '>=', dateFilter);
653
+ }
654
+
655
+ const conversationsSnapshot = await conversationsQuery.get();
656
+ const conversationCount = conversationsSnapshot.size;
657
+
658
+ // Get unique patients (sessionIds)
659
+ const uniqueSessionIds = new Set();
660
+ conversationsSnapshot.docs.forEach(doc => {
661
+ const sessionId = doc.data().sessionId;
662
+ if (sessionId) uniqueSessionIds.add(sessionId);
663
+ });
664
+
665
+ // Get total message count for this provider
666
+ let messageCount = 0;
667
+ for (const convoDoc of conversationsSnapshot.docs) {
668
+ const messagesSnapshot = await convoDoc.ref.collection('messages').get();
669
+ messageCount += messagesSnapshot.size;
670
+ }
671
+
672
+ providers.push({
673
+ id: providerId,
674
+ displayName: providerData.displayName || 'Unnamed Doctor',
675
+ email: providerData.email,
676
+ handle: providerData.handle,
677
+ specialty: providerData.specialty,
678
+ status: providerData.status || 'draft',
679
+ createdAt: providerData.createdAt,
680
+ conversationCount,
681
+ uniquePatients: uniqueSessionIds.size,
682
+ messageCount
683
+ });
684
+ }
685
+
686
+ // Calculate summary statistics
687
+ const totalDoctors = providers.length;
688
+ const activeDoctors = providers.filter(p => p.status === 'published').length;
689
+ const totalConversations = providers.reduce((sum, p) => sum + p.conversationCount, 0);
690
+
691
+ // Get unique patients across all providers
692
+ const allUniqueSessionIds = new Set();
693
+ let conversationsQuery = db.collection('conversations');
694
+ if (dateFilter) {
695
+ conversationsQuery = conversationsQuery.where('createdAt', '>=', dateFilter);
696
+ }
697
+ const allConversationsSnapshot = await conversationsQuery.get();
698
+ allConversationsSnapshot.docs.forEach(doc => {
699
+ const sessionId = doc.data().sessionId;
700
+ if (sessionId) allUniqueSessionIds.add(sessionId);
701
+ });
702
+
703
+ res.json({
704
+ summary: {
705
+ totalDoctors,
706
+ activeDoctors,
707
+ totalConversations,
708
+ uniquePatients: allUniqueSessionIds.size
709
+ },
710
+ doctors: providers,
711
+ period: timePeriod
712
+ });
713
+ } catch (error) {
714
+ console.error('Error getting analytics:', error);
715
+ res.status(500).json({ error: 'Internal server error' });
716
+ }
717
+ });
718
+
719
+ // Get provider by handle (public)
720
+ app.get('/api/provider', async (req, res) => {
721
+ try {
722
+ const handle = normalizeHandle(req.query.handle || '');
723
+ if (!handle) {
724
+ return res.status(400).json({ error: 'handle is required' });
725
+ }
726
+
727
+ const providerDoc = await findProviderByHandle(handle);
728
+ if (!providerDoc) {
729
+ return res.status(404).json({ error: 'Provider not found' });
730
+ }
731
+
732
+ const provider = providerDoc.data();
733
+ if (provider.status === 'suspended') {
734
+ return res.status(403).json({ error: 'This provider has been suspended.' });
735
+ }
736
+ // 'preview' resolves publicly so providers get an instant working link
737
+ // before credential verification completes (marked isPreview to the client).
738
+ if (provider.status !== 'published' && provider.status !== 'preview') {
739
+ return res.status(403).json({ error: 'This provider is not currently available.' });
740
+ }
741
+
742
+ res.json(mapProviderPublic(provider));
743
+ } catch (error) {
744
+ console.error('Error getting provider:', error);
745
+ res.status(500).json({ error: 'Internal server error' });
746
+ }
747
+ });
748
+
749
+ // Search providers (public)
750
+ app.get('/api/search-providers', rateLimit({ maxRequests: 30, windowMs: 60000 }), async (req, res) => {
751
+ try {
752
+ const query = (req.query.q || '').trim().toLowerCase();
753
+
754
+ if (!query || query.length < 2) {
755
+ return res.status(400).json({ error: 'Query must be at least 2 characters' });
756
+ }
757
+
758
+ const db = admin.firestore();
759
+
760
+ // Search by displayName and specialty
761
+ // Note: Firestore doesn't support full-text search, so we'll fetch all published providers
762
+ // and filter in memory. For production, consider Algolia or similar.
763
+ const snapshot = await db
764
+ .collection('providers')
765
+ .where('status', '==', 'published')
766
+ .limit(50)
767
+ .get();
768
+
769
+ if (snapshot.empty) {
770
+ return res.json({ providers: [] });
771
+ }
772
+
773
+ // Filter results based on query matching displayName or specialty
774
+ const providers = snapshot.docs
775
+ .map(doc => {
776
+ const data = doc.data();
777
+ return {
778
+ handle: data.handle,
779
+ displayName: data.displayName || 'Unknown Provider',
780
+ specialty: data.specialty || 'General Practice',
781
+ country: data.country || null
782
+ };
783
+ })
784
+ .filter(provider => {
785
+ const nameMatch = provider.displayName.toLowerCase().includes(query);
786
+ const specialtyMatch = provider.specialty.toLowerCase().includes(query);
787
+ const handleMatch = (provider.handle || '').toLowerCase().includes(query);
788
+ return nameMatch || specialtyMatch || handleMatch;
789
+ })
790
+ .slice(0, 10); // Return max 10 results
791
+
792
+ res.json({ providers });
793
+ } catch (error) {
794
+ console.error('Error searching providers:', error);
795
+ res.status(500).json({ error: 'Internal server error' });
796
+ }
797
+ });
798
+
799
+ // Generate QR code for a provider's public chat page (public, no auth required)
800
+ app.get('/api/qr/:handle', rateLimit({ maxRequests: 30, windowMs: 60000 }), async (req, res) => {
801
+ try {
802
+ const handle = normalizeHandle(req.params.handle);
803
+ if (!handle) {
804
+ return res.status(400).json({ error: 'Invalid handle' });
805
+ }
806
+
807
+ const providerDoc = await findProviderByHandle(handle);
808
+ if (!providerDoc) {
809
+ return res.status(404).json({ error: 'Provider not found' });
810
+ }
811
+
812
+ const provider = providerDoc.data();
813
+ // Consistent with /api/provider: preview links are live (the chat page
814
+ // discloses the unverified state), so their QR codes resolve too.
815
+ if (provider.status !== 'published' && provider.status !== 'preview') {
816
+ return res.status(403).json({ error: 'Provider is not available' });
817
+ }
818
+
819
+ // Build the public chat URL for this provider
820
+ const baseUrl = process.env.BASE_URL || `${req.protocol}://${req.get('host')}`;
821
+ const chatUrl = `${baseUrl}/${handle}`;
822
+
823
+ // Generate QR code as PNG buffer
824
+ const qrBuffer = await QRCode.toBuffer(chatUrl, {
825
+ type: 'png',
826
+ width: 300,
827
+ margin: 2,
828
+ errorCorrectionLevel: 'M'
829
+ });
830
+
831
+ res.setHeader('Content-Type', 'image/png');
832
+ res.setHeader('Cache-Control', 'public, max-age=3600');
833
+ res.send(qrBuffer);
834
+ } catch (error) {
835
+ console.error('Error generating QR code:', error);
836
+ res.status(500).json({ error: 'Failed to generate QR code' });
837
+ }
838
+ });
839
+
840
+ // Get current authenticated provider
841
+ app.get('/api/me', async (req, res) => {
842
+ try {
843
+ const user = await requireAuth(req, res);
844
+ if (!user) return;
845
+
846
+ const providerDoc = await findProviderByOwner(user.uid);
847
+ if (!providerDoc) {
848
+ return res.status(404).json({ error: 'Provider not found' });
849
+ }
850
+
851
+ const data = providerDoc.data();
852
+ res.json(mapProviderPrivate({ id: providerDoc.id, ...data }));
853
+ } catch (error) {
854
+ console.error('Error getting current provider:', error);
855
+ res.status(500).json({ error: 'Internal server error' });
856
+ }
857
+ });
858
+
859
+ // Create provider
860
+ app.post('/api/provider', async (req, res) => {
861
+ try {
862
+ const user = await requireAuth(req, res);
863
+ if (!user) return;
864
+
865
+ // Waitlist gate: block signups until launch date unless email is approved.
866
+ // Disabled entirely when WAITLIST_ENABLED=false (open self-serve).
867
+ if (WAITLIST_ENABLED && new Date() < LAUNCH_DATE) {
868
+ const db = admin.firestore();
869
+ const approved = await db.collection('waitlist')
870
+ .where('email', '==', (user.email || '').toLowerCase())
871
+ .where('approved', '==', true)
872
+ .limit(1)
873
+ .get();
874
+ if (approved.empty) {
875
+ return res.status(403).json({
876
+ error: 'EvalDoc launches April 1, 2026. Join the waitlist at evaldoctor.ai to be notified.',
877
+ code: 'WAITLIST_REQUIRED',
878
+ launchDate: LAUNCH_DATE.toISOString()
879
+ });
880
+ }
881
+ }
882
+
883
+ const existing = await findProviderByOwner(user.uid);
884
+ if (existing) {
885
+ return res.status(409).json({ error: 'Provider already exists' });
886
+ }
887
+
888
+ const body = req.body || {};
889
+ const rawHandle = normalizeHandle(body.handle);
890
+ if (!rawHandle || !HANDLE_REGEX.test(rawHandle) || RESERVED_HANDLES.has(rawHandle)) {
891
+ return res.status(400).json({ error: 'Invalid handle' });
892
+ }
893
+
894
+ const displayName = stripHtml(body.displayName || '').substring(0, 100);
895
+ if (!displayName) {
896
+ return res.status(400).json({ error: 'Display name is required' });
897
+ }
898
+ const specialty = stripHtml(body.specialty || '').substring(0, 100);
899
+ if (!specialty) {
900
+ return res.status(400).json({ error: 'Specialty is required' });
901
+ }
902
+
903
+ const licenseNumber = String(body.licenseNumber || '').trim();
904
+ if (licenseNumber && licenseNumber.length < 3) {
905
+ return res.status(400).json({ error: 'License number must be at least 3 characters' });
906
+ }
907
+
908
+ const brandPrimary = body.brandPrimaryColor && HEX_COLOR_REGEX.test(String(body.brandPrimaryColor).trim())
909
+ ? String(body.brandPrimaryColor).trim() : '#0e1a14';
910
+ const brandSecondary = body.brandSecondaryColor && HEX_COLOR_REGEX.test(String(body.brandSecondaryColor).trim())
911
+ ? String(body.brandSecondaryColor).trim() : '#4fb3a4';
912
+
913
+ const enabledTopics = sanitizeTopics(body.enabledTopics) || TOPIC_KEYS;
914
+ const providerData = {
915
+ ownerUid: user.uid,
916
+ handle: rawHandle,
917
+ displayName,
918
+ specialty,
919
+ country: stripHtml(body.country || '').substring(0, 60),
920
+ locale: String(body.locale || 'en').trim(),
921
+ photoUrl: body.photoUrl || null,
922
+ logoUrl: body.logoUrl || null,
923
+ brandPrimaryColor: brandPrimary,
924
+ brandSecondaryColor: brandSecondary,
925
+ welcomeMessage: stripHtml(body.welcomeMessage || 'Welcome. Choose a topic to get started.').substring(0, 500),
926
+ contactInfo: sanitizeContactInfo(body.contactInfo),
927
+ businessHours: body.businessHours || null,
928
+ enabledTopics,
929
+ // New doctor verification fields
930
+ licenseNumber: String(body.licenseNumber || '').trim(),
931
+ certificateUrl: body.certificateUrl || null,
932
+ certificateFileName: (body.certificateFileName || '').replace(/[/\\]/g, '_').slice(0, 255) || null,
933
+ verificationStatus: 'pending', // pending, verified, rejected — always starts as pending
934
+ // Payment/billing fields (set by billing setup later)
935
+ pricingModel: body.pricingModel || null, // Will be set during billing setup
936
+ paymentEnabled: body.paymentEnabled || false,
937
+ subscriptionPrice: body.subscriptionPrice || 0,
938
+ perChatPrice: body.perChatPrice || 0,
939
+ stripeAccountId: body.stripeAccountId || null,
940
+ stripeOnboardingComplete: body.stripeOnboardingComplete || false,
941
+ // Start in 'preview' so the provider has an instant, shareable working link
942
+ // (clearly marked as an unverified preview) before verification completes.
943
+ status: 'preview',
944
+ plan: 'free',
945
+ monthlyChatLimit: 50,
946
+ monthlyChatCount: 0,
947
+ monthlyChatCountMonth: getMonthKey(),
948
+ totalChatCount: 0,
949
+ totalEscalationCount: 0,
950
+ createdAt: nowTimestamp(),
951
+ updatedAt: nowTimestamp()
952
+ };
953
+
954
+ const db = admin.firestore();
955
+ const providerRef = db.collection('providers').doc(user.uid);
956
+ const handleRef = db.collection('handles').doc(rawHandle);
957
+
958
+ try {
959
+ await db.runTransaction(async (tx) => {
960
+ const handleDoc = await tx.get(handleRef);
961
+ if (handleDoc.exists) {
962
+ throw new Error('HANDLE_TAKEN');
963
+ }
964
+ tx.set(handleRef, {
965
+ providerId: providerRef.id,
966
+ createdAt: nowTimestamp()
967
+ });
968
+ tx.set(providerRef, providerData);
969
+ });
970
+ } catch (err) {
971
+ if (err.message === 'HANDLE_TAKEN') {
972
+ return res.status(409).json({ error: 'Handle already taken' });
973
+ }
974
+ throw err;
975
+ }
976
+
977
+ res.json(mapProviderPrivate({ id: providerRef.id, ...providerData }));
978
+ } catch (error) {
979
+ console.error('Error creating provider:', error);
980
+ res.status(500).json({ error: 'Unable to create provider' });
981
+ }
982
+ });
983
+
984
+ // Update provider
985
+ app.post('/api/provider/update', async (req, res) => {
986
+ try {
987
+ const user = await requireAuth(req, res);
988
+ if (!user) return;
989
+
990
+ const providerDoc = await findProviderByOwner(user.uid);
991
+ if (!providerDoc) {
992
+ return res.status(404).json({ error: 'Provider not found' });
993
+ }
994
+
995
+ const update = sanitizeProviderUpdate(req.body || {});
996
+ if (!Object.keys(update).length) {
997
+ return res.status(400).json({ error: 'No valid fields to update' });
998
+ }
999
+
1000
+ // Full public publishing requires verified credentials. Doctors can use
1001
+ // 'preview' status immediately for a working link while verification is pending.
1002
+ if (update.status === 'published') {
1003
+ const currentData = providerDoc.data();
1004
+ if (currentData.verificationStatus !== 'verified') {
1005
+ return res.status(403).json({
1006
+ error: 'Your credentials are still being verified. You can share your preview link now and switch to fully published once verification completes.',
1007
+ code: 'VERIFICATION_REQUIRED',
1008
+ previewAvailable: true
1009
+ });
1010
+ }
1011
+ }
1012
+
1013
+ update.updatedAt = nowTimestamp();
1014
+ await providerDoc.ref.set(update, { merge: true });
1015
+
1016
+ const updated = await providerDoc.ref.get();
1017
+ res.json(mapProviderPrivate({ id: updated.id, ...updated.data() }));
1018
+ } catch (error) {
1019
+ console.error('Error updating provider:', error);
1020
+ res.status(500).json({ error: 'Unable to update provider' });
1021
+ }
1022
+ });
1023
+
1024
+ // Verification status — self-serve transparency (Phase 1)
1025
+ // Returns where the provider is in the credential-verification journey,
1026
+ // an estimated SLA, and what action (if any) they can take next.
1027
+ const VERIFICATION_SLA_HOURS = 48;
1028
+ app.get('/api/provider/verification-status', async (req, res) => {
1029
+ try {
1030
+ const ctx = await requireProvider(req, res);
1031
+ if (!ctx) return;
1032
+ const p = ctx.providerDoc.data();
1033
+ const status = p.verificationStatus || 'pending';
1034
+
1035
+ const messages = {
1036
+ pending: `Your credentials are under review. Most reviews complete within ${VERIFICATION_SLA_HOURS} hours. You can share your preview link in the meantime.`,
1037
+ verified: 'Your credentials are verified. You can publish your public link.',
1038
+ rejected: 'We could not verify your credentials. Please review the reason and resubmit.'
1039
+ };
1040
+
1041
+ res.json({
1042
+ verificationStatus: status,
1043
+ slaHours: VERIFICATION_SLA_HOURS,
1044
+ message: messages[status] || messages.pending,
1045
+ canResubmit: status === 'rejected',
1046
+ canUsePreview: status !== 'verified',
1047
+ rejectionReason: status === 'rejected' ? (p.verificationRejectionReason || null) : null,
1048
+ hasCertificate: !!p.certificateUrl,
1049
+ submittedAt: p.createdAt || null
1050
+ });
1051
+ } catch (error) {
1052
+ console.error('Verification status error:', error);
1053
+ res.status(500).json({ error: 'Unable to load verification status' });
1054
+ }
1055
+ });
1056
+
1057
+ // Onboarding checklist — guided activation (Phase 1)
1058
+ // Computes which setup steps are done so the dashboard can render a checklist
1059
+ // and a single clear "next action" instead of a blank page.
1060
+ app.get('/api/provider/onboarding-status', async (req, res) => {
1061
+ try {
1062
+ const ctx = await requireProvider(req, res);
1063
+ if (!ctx) return;
1064
+ const p = ctx.providerDoc.data();
1065
+
1066
+ const steps = [
1067
+ { key: 'profile', label: 'Add your name and specialty', done: !!(p.displayName && p.specialty) },
1068
+ { key: 'branding', label: 'Customize your branding', done: !!(p.welcomeMessage && p.brandPrimaryColor) },
1069
+ { key: 'topics', label: 'Choose your topics', done: Array.isArray(p.enabledTopics) && p.enabledTopics.length > 0 },
1070
+ { key: 'credentials', label: 'Upload your medical license', done: !!p.certificateUrl },
1071
+ { key: 'preview', label: 'Share your preview link', done: p.status === 'preview' || p.status === 'published' },
1072
+ { key: 'billing', label: 'Set up payments (optional)', done: !!p.stripeOnboardingComplete, optional: true },
1073
+ { key: 'published', label: 'Go live (after verification)', done: p.status === 'published' }
1074
+ ];
1075
+
1076
+ const required = steps.filter(s => !s.optional);
1077
+ const completedRequired = required.filter(s => s.done).length;
1078
+ const nextStep = steps.find(s => !s.done && !s.optional) || null;
1079
+
1080
+ res.json({
1081
+ steps,
1082
+ completedRequired,
1083
+ totalRequired: required.length,
1084
+ percentComplete: Math.round((completedRequired / required.length) * 100),
1085
+ nextStep: nextStep ? { key: nextStep.key, label: nextStep.label } : null,
1086
+ isComplete: completedRequired === required.length
1087
+ });
1088
+ } catch (error) {
1089
+ console.error('Onboarding status error:', error);
1090
+ res.status(500).json({ error: 'Unable to load onboarding status' });
1091
+ }
1092
+ });
1093
+
1094
+ // Export all account data — GDPR right to data portability (Phase 2)
1095
+ app.get('/api/account/export', async (req, res) => {
1096
+ try {
1097
+ const ctx = await requireProvider(req, res);
1098
+ if (!ctx) return;
1099
+ const { user, providerDoc } = ctx;
1100
+ const providerId = providerDoc.id;
1101
+ const db = admin.firestore();
1102
+
1103
+ const [threadsSnap, convosSnap] = await Promise.all([
1104
+ db.collection('threads').where('doctorId', '==', providerId).limit(1000).get(),
1105
+ db.collection('conversations').where('providerId', '==', providerId).limit(1000).get()
1106
+ ]);
1107
+
1108
+ const exportData = {
1109
+ exportedAt: new Date().toISOString(),
1110
+ account: { uid: user.uid, email: user.email || null },
1111
+ provider: mapProviderPrivate({ id: providerId, ...providerDoc.data() }),
1112
+ threads: threadsSnap.docs.map(d => ({ id: d.id, ...d.data() })),
1113
+ conversations: convosSnap.docs.map(d => ({ id: d.id, ...d.data() })),
1114
+ counts: { threads: threadsSnap.size, conversations: convosSnap.size }
1115
+ };
1116
+
1117
+ res.setHeader('Content-Type', 'application/json');
1118
+ res.setHeader('Content-Disposition', `attachment; filename="evaldoc-export-${providerId}.json"`);
1119
+ res.json(exportData);
1120
+ } catch (error) {
1121
+ console.error('Account export error:', error);
1122
+ res.status(500).json({ error: 'Unable to export account data' });
1123
+ }
1124
+ });
1125
+
1126
+ // Delete account — GDPR right to erasure (Phase 2)
1127
+ // Cascades: provider doc, handle reservation, threads (+messages), conversations.
1128
+ app.delete('/api/account', async (req, res) => {
1129
+ try {
1130
+ const user = await requireAuth(req, res);
1131
+ if (!user) return;
1132
+
1133
+ // Require explicit confirmation to prevent accidental deletion.
1134
+ if (!req.body || req.body.confirm !== 'DELETE') {
1135
+ return res.status(400).json({ error: 'Confirmation required. Send { "confirm": "DELETE" } to proceed.' });
1136
+ }
1137
+
1138
+ const providerDoc = await findProviderByOwner(user.uid);
1139
+ if (!providerDoc) {
1140
+ return res.status(404).json({ error: 'Provider not found' });
1141
+ }
1142
+ const providerId = providerDoc.id;
1143
+ const p = providerDoc.data();
1144
+ const db = admin.firestore();
1145
+
1146
+ // Delete threads and their message subcollections
1147
+ const threadsSnap = await db.collection('threads').where('doctorId', '==', providerId).limit(1000).get();
1148
+ for (const threadDoc of threadsSnap.docs) {
1149
+ const msgsSnap = await threadDoc.ref.collection('messages').limit(1000).get();
1150
+ const batch = db.batch();
1151
+ msgsSnap.docs.forEach(m => batch.delete(m.ref));
1152
+ batch.delete(threadDoc.ref);
1153
+ await batch.commit();
1154
+ }
1155
+
1156
+ // Delete conversations and their message subcollections
1157
+ const convosSnap = await db.collection('conversations').where('providerId', '==', providerId).limit(1000).get();
1158
+ for (const convoDoc of convosSnap.docs) {
1159
+ const msgsSnap = await convoDoc.ref.collection('messages').limit(1000).get();
1160
+ const batch = db.batch();
1161
+ msgsSnap.docs.forEach(m => batch.delete(m.ref));
1162
+ batch.delete(convoDoc.ref);
1163
+ await batch.commit();
1164
+ }
1165
+
1166
+ // Release the handle reservation and delete the provider record
1167
+ if (p.handle) {
1168
+ await db.collection('handles').doc(p.handle).delete().catch(() => {});
1169
+ }
1170
+ await providerDoc.ref.delete();
1171
+
1172
+ logAuditEvent({
1173
+ action: 'account_deleted',
1174
+ actorId: user.uid,
1175
+ actorType: 'doctor',
1176
+ resourceType: 'provider',
1177
+ resourceId: providerId,
1178
+ meta: { threadsDeleted: threadsSnap.size, conversationsDeleted: convosSnap.size }
1179
+ }).catch(() => {});
1180
+
1181
+ res.json({
1182
+ success: true,
1183
+ deleted: { provider: providerId, threads: threadsSnap.size, conversations: convosSnap.size }
1184
+ });
1185
+ } catch (error) {
1186
+ console.error('Account deletion error:', error);
1187
+ res.status(500).json({ error: 'Unable to delete account' });
1188
+ }
1189
+ });
1190
+
1191
+ // Public pricing — business-model transparency (Phase 3)
1192
+ // Primary model is B2B SaaS (the provider/clinic subscribes). Patient-pay is an
1193
+ // optional per-provider add-on. This endpoint powers a public pricing page.
1194
+ const SAAS_PLANS = [
1195
+ {
1196
+ key: 'free', name: 'Free', priceMonthly: 0, currency: 'usd',
1197
+ seats: 1, monthlyChatLimit: 50,
1198
+ features: ['1 provider', '50 patient chats/mo', 'Safety pipeline', 'Basic analytics', 'Branded link + QR']
1199
+ },
1200
+ {
1201
+ key: 'pro', name: 'Pro', priceMonthly: 29, currency: 'usd',
1202
+ seats: 1, monthlyChatLimit: 0,
1203
+ features: ['Unlimited patient chats', 'Custom branding', 'Full analytics', 'AI draft replies', 'Priority verification']
1204
+ },
1205
+ {
1206
+ key: 'clinic', name: 'Clinic', priceMonthly: 99, currency: 'usd',
1207
+ seats: 5, monthlyChatLimit: 0,
1208
+ features: ['Up to 5 providers', 'Team inbox', 'Shared analytics', 'Clinic branding', 'Volume support']
1209
+ },
1210
+ {
1211
+ key: 'enterprise', name: 'Enterprise', priceMonthly: null, currency: 'usd',
1212
+ seats: null, monthlyChatLimit: 0,
1213
+ features: ['Unlimited providers', 'SSO/SAML', 'Data residency', 'DPA & BAA', 'Dedicated support', 'Custom SLAs']
1214
+ }
1215
+ ];
1216
+
1217
+ app.get('/api/pricing', (req, res) => {
1218
+ res.json({
1219
+ model: 'b2b_saas',
1220
+ description: 'Providers and clinics subscribe to EvalDoc. Patient-pay is an optional add-on configured per provider.',
1221
+ plans: SAAS_PLANS,
1222
+ patientPayAddOn: {
1223
+ available: true,
1224
+ platformFeePercent: 10,
1225
+ note: 'Optional. Providers may charge patients per chat or via subscription. Platform fee applies on top of payment-processor fees.'
1226
+ }
1227
+ });
1228
+ });
1229
+
1230
+ // Patient-facing cost for a specific provider (public)
1231
+ app.get('/api/pricing/:handle', async (req, res) => {
1232
+ try {
1233
+ const handle = normalizeHandle(req.params.handle);
1234
+ if (!handle) return res.status(400).json({ error: 'Invalid handle' });
1235
+
1236
+ const providerDoc = await findProviderByHandle(handle);
1237
+ if (!providerDoc) return res.status(404).json({ error: 'Provider not found' });
1238
+
1239
+ const p = providerDoc.data();
1240
+ // Don't expose pricing for providers that aren't live (no enumeration of drafts).
1241
+ if (p.status !== 'published' && p.status !== 'preview') {
1242
+ return res.status(404).json({ error: 'Provider not found' });
1243
+ }
1244
+ const paid = !!p.paymentEnabled && p.pricingModel && p.pricingModel !== 'free';
1245
+ res.json({
1246
+ handle,
1247
+ free: !paid,
1248
+ pricingModel: paid ? p.pricingModel : 'free',
1249
+ perChatPrice: paid ? (p.perChatPrice || 0) : 0,
1250
+ subscriptionPrice: paid ? (p.subscriptionPrice || 0) : 0,
1251
+ currency: p.currency || 'usd'
1252
+ });
1253
+ } catch (error) {
1254
+ console.error('Provider pricing error:', error);
1255
+ res.status(500).json({ error: 'Unable to load pricing' });
1256
+ }
1257
+ });
1258
+
1259
+ // ============================================
1260
+ // ORGANIZATIONS & TEAM (multi-provider clinics) — Phase A/B
1261
+ // ============================================
1262
+
1263
+ // Current org + members + seat usage
1264
+ app.get('/api/org', async (req, res) => {
1265
+ try {
1266
+ const membership = await requireMembership(req, res);
1267
+ if (!membership) return;
1268
+
1269
+ const db = admin.firestore();
1270
+ const membersSnap = await db.collection('organizations').doc(membership.orgId)
1271
+ .collection('members').get();
1272
+ const members = membersSnap.docs.map(d => {
1273
+ const m = d.data();
1274
+ return { uid: m.uid, email: m.email, role: m.role, status: m.status, providerId: m.providerId || null };
1275
+ });
1276
+ const activeCount = members.filter(m => m.status === 'active').length;
1277
+
1278
+ res.json({
1279
+ org: {
1280
+ id: membership.org.id,
1281
+ name: membership.org.name,
1282
+ type: membership.org.type,
1283
+ plan: membership.org.plan,
1284
+ seats: membership.org.seats
1285
+ },
1286
+ role: membership.role,
1287
+ members,
1288
+ seatLimit: orgs.seatLimitForPlan(membership.org.plan),
1289
+ seatsRemaining: orgs.seatsRemaining(membership.org.plan, activeCount),
1290
+ activeMembers: activeCount
1291
+ });
1292
+ } catch (error) {
1293
+ console.error('Get org error:', error);
1294
+ res.status(500).json({ error: 'Unable to load organization' });
1295
+ }
1296
+ });
1297
+
1298
+ // Invite a member
1299
+ app.post('/api/org/invite', express.json(), async (req, res) => {
1300
+ try {
1301
+ const membership = await requireMembership(req, res, 'members.invite');
1302
+ if (!membership) return;
1303
+ const user = membership.user;
1304
+
1305
+ const { ok, value } = validate({
1306
+ email: { type: 'email', required: true },
1307
+ role: { type: 'string', default: 'clinician', maxLength: 20 }
1308
+ }, req.body || {});
1309
+ if (!ok) {
1310
+ return sendError(req, res, 400, 'A valid email is required');
1311
+ }
1312
+ const email = value.email;
1313
+ const role = value.role;
1314
+ if (!orgs.canAssignRole(membership.role, role)) {
1315
+ return sendError(req, res, 403, `You cannot assign the role "${role}"`);
1316
+ }
1317
+
1318
+ // Seat enforcement: active members + outstanding pending invites must fit the plan.
1319
+ const db = admin.firestore();
1320
+ const activeCount = await countActiveMembers(membership.orgId);
1321
+ const pendingSnap = await db.collection('invitations')
1322
+ .where('orgId', '==', membership.orgId)
1323
+ .where('status', '==', 'pending').get();
1324
+ const projected = activeCount + pendingSnap.size;
1325
+ if (!orgs.hasSeatAvailable(membership.org.plan, projected)) {
1326
+ return res.status(402).json({
1327
+ error: 'No seats available on your current plan. Upgrade to add more members.',
1328
+ code: 'SEAT_LIMIT_REACHED',
1329
+ seatLimit: orgs.seatLimitForPlan(membership.org.plan)
1330
+ });
1331
+ }
1332
+
1333
+ const token = crypto.randomBytes(24).toString('hex');
1334
+ const invite = {
1335
+ orgId: membership.orgId,
1336
+ email,
1337
+ role,
1338
+ token,
1339
+ status: 'pending',
1340
+ invitedBy: user.uid,
1341
+ createdAt: nowTimestamp(),
1342
+ expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000
1343
+ };
1344
+ await db.collection('invitations').doc(token).set(invite);
1345
+
1346
+ logAuditEvent({
1347
+ action: 'member_invited', actorId: user.uid, actorType: 'doctor',
1348
+ resourceType: 'organization', resourceId: membership.orgId, meta: { email, role }
1349
+ }).catch(() => {});
1350
+
1351
+ res.json({ success: true, invite: { email, role, token, status: 'pending', expiresAt: invite.expiresAt } });
1352
+ } catch (error) {
1353
+ console.error('Invite member error:', error);
1354
+ res.status(500).json({ error: 'Unable to send invite' });
1355
+ }
1356
+ });
1357
+
1358
+ // Accept an invite (the invited, authenticated user calls this)
1359
+ app.post('/api/org/invite/accept', express.json(), async (req, res) => {
1360
+ try {
1361
+ const user = await requireAuth(req, res);
1362
+ if (!user) return;
1363
+
1364
+ const token = String(req.body.token || '').trim();
1365
+ if (!token) return res.status(400).json({ error: 'Invite token is required' });
1366
+
1367
+ const db = admin.firestore();
1368
+ const inviteDoc = await db.collection('invitations').doc(token).get();
1369
+ if (!inviteDoc.exists) return res.status(404).json({ error: 'Invite not found' });
1370
+ const invite = inviteDoc.data();
1371
+
1372
+ if (invite.status !== 'pending') return res.status(409).json({ error: 'This invite is no longer valid' });
1373
+ if (invite.expiresAt && Date.now() > invite.expiresAt) {
1374
+ await inviteDoc.ref.set({ status: 'expired' }, { merge: true });
1375
+ return res.status(410).json({ error: 'This invite has expired' });
1376
+ }
1377
+ if ((user.email || '').toLowerCase() !== invite.email) {
1378
+ return res.status(403).json({ error: 'This invite was sent to a different email address' });
1379
+ }
1380
+
1381
+ // One active org per user in v1.
1382
+ const existing = await db.collection('memberships').doc(user.uid).get();
1383
+ if (existing.exists && existing.data().orgId !== invite.orgId) {
1384
+ return res.status(409).json({ error: 'You already belong to an organization' });
1385
+ }
1386
+
1387
+ // Re-check seat availability at accept time (guards against races).
1388
+ const activeCount = await countActiveMembers(invite.orgId);
1389
+ const orgDoc = await db.collection('organizations').doc(invite.orgId).get();
1390
+ if (!orgDoc.exists) return res.status(404).json({ error: 'Organization no longer exists' });
1391
+ if (!orgs.hasSeatAvailable(orgDoc.data().plan, activeCount)) {
1392
+ return res.status(402).json({ error: 'No seats available', code: 'SEAT_LIMIT_REACHED' });
1393
+ }
1394
+
1395
+ const memberData = {
1396
+ uid: user.uid, email: user.email || null, role: invite.role,
1397
+ providerId: null, status: 'active', joinedAt: nowTimestamp()
1398
+ };
1399
+ await db.collection('organizations').doc(invite.orgId).collection('members').doc(user.uid).set(memberData);
1400
+ await db.collection('memberships').doc(user.uid).set({ orgId: invite.orgId, role: invite.role });
1401
+ await inviteDoc.ref.set({ status: 'accepted', acceptedAt: nowTimestamp(), acceptedBy: user.uid }, { merge: true });
1402
+
1403
+ res.json({ success: true, orgId: invite.orgId, role: invite.role });
1404
+ } catch (error) {
1405
+ console.error('Accept invite error:', error);
1406
+ res.status(500).json({ error: 'Unable to accept invite' });
1407
+ }
1408
+ });
1409
+
1410
+ // List members
1411
+ app.get('/api/org/members', async (req, res) => {
1412
+ try {
1413
+ const membership = await requireMembership(req, res);
1414
+ if (!membership) return;
1415
+
1416
+ const db = admin.firestore();
1417
+ const snap = await db.collection('organizations').doc(membership.orgId).collection('members').get();
1418
+ res.json({ members: snap.docs.map(d => {
1419
+ const m = d.data();
1420
+ return { uid: m.uid, email: m.email, role: m.role, status: m.status };
1421
+ }) });
1422
+ } catch (error) {
1423
+ console.error('List members error:', error);
1424
+ res.status(500).json({ error: 'Unable to list members' });
1425
+ }
1426
+ });
1427
+
1428
+ // Change a member's role
1429
+ app.patch('/api/org/members/:uid', express.json(), async (req, res) => {
1430
+ try {
1431
+ const membership = await requireMembership(req, res, 'members.role');
1432
+ if (!membership) return;
1433
+ const user = membership.user;
1434
+
1435
+ const targetUid = req.params.uid;
1436
+ const newRole = String(req.body.role || '');
1437
+ const db = admin.firestore();
1438
+ const targetRef = db.collection('organizations').doc(membership.orgId).collection('members').doc(targetUid);
1439
+ const targetDoc = await targetRef.get();
1440
+ if (!targetDoc.exists) return res.status(404).json({ error: 'Member not found' });
1441
+
1442
+ const targetRole = targetDoc.data().role;
1443
+ if (!orgs.canManageMember(membership.role, targetRole)) {
1444
+ return res.status(403).json({ error: 'You cannot manage this member' });
1445
+ }
1446
+ if (!orgs.canAssignRole(membership.role, newRole)) {
1447
+ return res.status(403).json({ error: `You cannot assign the role "${newRole}"` });
1448
+ }
1449
+
1450
+ await targetRef.set({ role: newRole }, { merge: true });
1451
+ await db.collection('memberships').doc(targetUid).set({ orgId: membership.orgId, role: newRole }, { merge: true });
1452
+
1453
+ logAuditEvent({
1454
+ action: 'member_role_changed', actorId: user.uid, actorType: 'doctor',
1455
+ resourceType: 'organization', resourceId: membership.orgId, meta: { targetUid, from: targetRole, to: newRole }
1456
+ }).catch(() => {});
1457
+
1458
+ res.json({ success: true, uid: targetUid, role: newRole });
1459
+ } catch (error) {
1460
+ console.error('Change role error:', error);
1461
+ res.status(500).json({ error: 'Unable to change role' });
1462
+ }
1463
+ });
1464
+
1465
+ // Remove a member
1466
+ app.delete('/api/org/members/:uid', async (req, res) => {
1467
+ try {
1468
+ const membership = await requireMembership(req, res, 'members.remove');
1469
+ if (!membership) return;
1470
+ const user = membership.user;
1471
+
1472
+ const targetUid = req.params.uid;
1473
+ if (targetUid === user.uid) {
1474
+ return res.status(400).json({ error: 'Use account deletion to remove yourself' });
1475
+ }
1476
+
1477
+ const db = admin.firestore();
1478
+ const targetRef = db.collection('organizations').doc(membership.orgId).collection('members').doc(targetUid);
1479
+ const targetDoc = await targetRef.get();
1480
+ if (!targetDoc.exists) return res.status(404).json({ error: 'Member not found' });
1481
+
1482
+ if (!orgs.canManageMember(membership.role, targetDoc.data().role)) {
1483
+ return res.status(403).json({ error: 'You cannot remove this member' });
1484
+ }
1485
+
1486
+ await targetRef.delete();
1487
+ await db.collection('memberships').doc(targetUid).delete().catch(() => {});
1488
+
1489
+ logAuditEvent({
1490
+ action: 'member_removed', actorId: user.uid, actorType: 'doctor',
1491
+ resourceType: 'organization', resourceId: membership.orgId, meta: { targetUid }
1492
+ }).catch(() => {});
1493
+
1494
+ res.json({ success: true, removed: targetUid });
1495
+ } catch (error) {
1496
+ console.error('Remove member error:', error);
1497
+ res.status(500).json({ error: 'Unable to remove member' });
1498
+ }
1499
+ });
1500
+
1501
+ // Collect the provider ids owned by members of an org.
1502
+ async function orgProviderIds(orgId) {
1503
+ const db = admin.firestore();
1504
+ const snap = await db.collection('organizations').doc(orgId).collection('members').get();
1505
+ return snap.docs.map(d => d.data().providerId).filter(Boolean);
1506
+ }
1507
+
1508
+ // Clinic-wide inbox — every patient thread across the org's providers.
1509
+ // RBAC: only owner/admin (inbox.viewAll). Clinicians use their own /api/doctor/threads.
1510
+ app.get('/api/org/inbox', async (req, res) => {
1511
+ try {
1512
+ const membership = await requireMembership(req, res, 'inbox.viewAll');
1513
+ if (!membership) return;
1514
+
1515
+ const providerIds = await orgProviderIds(membership.orgId);
1516
+ if (!providerIds.length) return res.json({ threads: [] });
1517
+
1518
+ const limit = Math.min(parseInt(req.query.limit, 10) || 50, 100);
1519
+ const db = admin.firestore();
1520
+ // Firestore 'in' supports up to 10 values; clinic seat caps keep us well under.
1521
+ const snapshot = await db.collection('threads')
1522
+ .where('doctorId', 'in', providerIds.slice(0, 10))
1523
+ .orderBy('lastMessageAt', 'desc')
1524
+ .limit(limit)
1525
+ .get();
1526
+
1527
+ const threads = snapshot.docs.map(d => {
1528
+ const t = d.data();
1529
+ return {
1530
+ id: d.id, doctorId: t.doctorId, status: t.status,
1531
+ triageCategory: t.triageCategory, isEscalated: t.isEscalated,
1532
+ lastUserMessage: t.lastUserMessage, lastMessageAt: t.lastMessageAt,
1533
+ patientName: t.patientName || t.clientName || null
1534
+ };
1535
+ });
1536
+ res.json({ threads, providerCount: providerIds.length });
1537
+ } catch (error) {
1538
+ console.error('Org inbox error:', error);
1539
+ res.status(500).json({ error: 'Unable to load clinic inbox' });
1540
+ }
1541
+ });
1542
+
1543
+ // Clinic-wide aggregate analytics across all the org's providers.
1544
+ app.get('/api/org/analytics', async (req, res) => {
1545
+ try {
1546
+ const membership = await requireMembership(req, res, 'inbox.viewAll');
1547
+ if (!membership) return;
1548
+
1549
+ const db = admin.firestore();
1550
+ const membersSnap = await db.collection('organizations').doc(membership.orgId).collection('members').get();
1551
+ const providerIds = membersSnap.docs.map(d => d.data().providerId).filter(Boolean);
1552
+
1553
+ let totalChats = 0, totalEscalations = 0;
1554
+ // Fetch all member providers in parallel rather than serially (N+1 → 1 round-trip fan-out).
1555
+ const providerDocs = await Promise.all(
1556
+ providerIds.map(pid => db.collection('providers').doc(pid).get())
1557
+ );
1558
+ const perProvider = [];
1559
+ for (const pDoc of providerDocs) {
1560
+ if (!pDoc.exists) continue;
1561
+ const p = pDoc.data();
1562
+ const chats = p.totalChatCount || 0;
1563
+ const esc = p.totalEscalationCount || 0;
1564
+ totalChats += chats;
1565
+ totalEscalations += esc;
1566
+ perProvider.push({ providerId: pDoc.id, displayName: p.displayName || null, handle: p.handle || null, chats, escalations: esc });
1567
+ }
1568
+
1569
+ res.json({
1570
+ memberCount: membersSnap.size,
1571
+ providerCount: providerIds.length,
1572
+ totalChats,
1573
+ totalEscalations,
1574
+ escalationRate: totalChats > 0 ? Math.round((totalEscalations / totalChats) * 100) : 0,
1575
+ perProvider
1576
+ });
1577
+ } catch (error) {
1578
+ console.error('Org analytics error:', error);
1579
+ res.status(500).json({ error: 'Unable to load clinic analytics' });
1580
+ }
1581
+ });
1582
+
1583
+ // Doctor thread API routes - with triage filtering (Component 2)
1584
+ app.get('/api/doctor/threads', async (req, res) => {
1585
+ try {
1586
+ const user = await requireAuth(req, res);
1587
+ if (!user) return;
1588
+
1589
+ const providerDoc = await findProviderByOwner(user.uid);
1590
+ if (!providerDoc) {
1591
+ return res.status(404).json({ error: 'Provider not found' });
1592
+ }
1593
+
1594
+ const limit = Math.min(parseInt(req.query.limit, 10) || 50, 100);
1595
+ const status = req.query.status || null;
1596
+ const triage = req.query.triage || null;
1597
+ const search = req.query.q ? String(req.query.q).trim().toLowerCase() : null;
1598
+
1599
+ const db = admin.firestore();
1600
+ let query = db
1601
+ .collection('threads')
1602
+ .where('doctorId', '==', providerDoc.id);
1603
+
1604
+ if (status) {
1605
+ query = query.where('status', '==', status);
1606
+ }
1607
+
1608
+ if (triage) {
1609
+ query = query.where('triageCategory', '==', triage);
1610
+ }
1611
+
1612
+ const fetchLimit = search ? 200 : limit;
1613
+ const snapshot = await query
1614
+ .orderBy('lastMessageAt', 'desc')
1615
+ .limit(fetchLimit)
1616
+ .get();
1617
+
1618
+ let threads = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
1619
+
1620
+ if (search) {
1621
+ threads = threads.filter(t => {
1622
+ const fields = [
1623
+ t.patientName, t.clientName, t.patientEmail, t.clientEmail,
1624
+ t.lastUserMessage, t.topic
1625
+ ].filter(Boolean).map(f => String(f).toLowerCase());
1626
+ return fields.some(f => f.includes(search));
1627
+ }).slice(0, limit);
1628
+ }
1629
+
1630
+ res.json({ threads });
1631
+ } catch (error) {
1632
+ console.error('Get threads error:', error);
1633
+ res.status(500).json({ error: 'Internal server error' });
1634
+ }
1635
+ });
1636
+
1637
+ app.get('/api/doctor/thread/:threadId', async (req, res) => {
1638
+ try {
1639
+ const user = await requireAuth(req, res);
1640
+ if (!user) return;
1641
+
1642
+ const providerDoc = await findProviderByOwner(user.uid);
1643
+ if (!providerDoc) {
1644
+ return res.status(404).json({ error: 'Provider not found' });
1645
+ }
1646
+
1647
+ const threadId = req.params.threadId;
1648
+ const db = admin.firestore();
1649
+ const threadDoc = await db.collection('threads').doc(threadId).get();
1650
+
1651
+ if (!threadDoc.exists || threadDoc.data().doctorId !== providerDoc.id) {
1652
+ return res.status(404).json({ error: 'Thread not found' });
1653
+ }
1654
+
1655
+ const messagesSnapshot = await threadDoc.ref
1656
+ .collection('messages')
1657
+ .orderBy('createdAt', 'asc')
1658
+ .limit(100)
1659
+ .get();
1660
+
1661
+ const messages = messagesSnapshot.docs.map((doc) => ({
1662
+ id: doc.id,
1663
+ ...doc.data()
1664
+ }));
1665
+
1666
+ // Mark as read by doctor
1667
+ await threadDoc.ref.update({ unreadByDoctor: 0 });
1668
+
1669
+ // Audit log — doctor accessed patient thread
1670
+ logAuditEvent({
1671
+ action: 'view_thread',
1672
+ actorId: user.uid,
1673
+ actorType: 'doctor',
1674
+ resourceType: 'thread',
1675
+ resourceId: threadId
1676
+ }).catch(() => {});
1677
+
1678
+ res.json({
1679
+ thread: { id: threadDoc.id, ...threadDoc.data() },
1680
+ messages
1681
+ });
1682
+ } catch (error) {
1683
+ console.error('Get thread error:', error);
1684
+ res.status(500).json({ error: 'Internal server error' });
1685
+ }
1686
+ });
1687
+
1688
+ app.post('/api/doctor/message', express.json(), async (req, res) => {
1689
+ try {
1690
+ const user = await requireAuth(req, res);
1691
+ if (!user) return;
1692
+
1693
+ const providerDoc = await findProviderByOwner(user.uid);
1694
+ if (!providerDoc) {
1695
+ return res.status(404).json({ error: 'Provider not found' });
1696
+ }
1697
+
1698
+ const { threadId, message, override } = req.body;
1699
+
1700
+ if (!threadId || !message) {
1701
+ return res.status(400).json({ error: 'threadId and message are required' });
1702
+ }
1703
+ if (String(message).length > 10000) {
1704
+ return res.status(400).json({ error: 'Message exceeds maximum length of 10000 characters' });
1705
+ }
1706
+
1707
+ const db = admin.firestore();
1708
+ const threadDoc = await db.collection('threads').doc(threadId).get();
1709
+
1710
+ if (!threadDoc.exists || threadDoc.data().doctorId !== providerDoc.id) {
1711
+ return res.status(404).json({ error: 'Thread not found' });
1712
+ }
1713
+
1714
+ // Add doctor's message
1715
+ const messageData = {
1716
+ threadId,
1717
+ role: 'doctor',
1718
+ message,
1719
+ isDoctorManual: true,
1720
+ doctorUserId: user.uid,
1721
+ readByPatient: false,
1722
+ readByDoctor: true,
1723
+ wasFiltered: false,
1724
+ isAiGenerated: false,
1725
+ createdAt: admin.firestore.FieldValue.serverTimestamp()
1726
+ };
1727
+
1728
+ await threadDoc.ref.collection('messages').add(messageData);
1729
+
1730
+ // Update thread
1731
+ const threadData = threadDoc.data();
1732
+ const updates = {
1733
+ lastMessageAt: admin.firestore.FieldValue.serverTimestamp(),
1734
+ lastMessageText: message.substring(0, 100),
1735
+ lastMessageRole: 'doctor',
1736
+ messageCount: admin.firestore.FieldValue.increment(1),
1737
+ unreadByPatient: admin.firestore.FieldValue.increment(1),
1738
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
1739
+ // Clear draft when doctor sends a manual message
1740
+ aiDraftStatus: threadData.aiDraftResponse ? 'rejected' : null
1741
+ };
1742
+
1743
+ if (override) {
1744
+ updates.doctorOverride = true;
1745
+ }
1746
+
1747
+ await threadDoc.ref.update(updates);
1748
+
1749
+ // Fire-and-forget: Capture doctor response as training signal (Component 5)
1750
+ (async () => {
1751
+ try {
1752
+ // Get the patient's last message from the thread
1753
+ const recentMessages = await threadDoc.ref.collection('messages')
1754
+ .orderBy('createdAt', 'desc')
1755
+ .limit(5)
1756
+ .get();
1757
+
1758
+ const patientMsg = recentMessages.docs
1759
+ .map(d => d.data())
1760
+ .find(m => m.role === 'patient' || m.role === 'user');
1761
+
1762
+ if (patientMsg) {
1763
+ await captureDoctorExample({
1764
+ doctorId: providerDoc.id,
1765
+ threadId,
1766
+ topic: threadData.currentTopic,
1767
+ patientMessage: patientMsg.message || patientMsg.content,
1768
+ doctorResponse: message,
1769
+ wasAiDraft: false,
1770
+ aiDraftOriginal: null
1771
+ });
1772
+ }
1773
+ } catch (err) {
1774
+ console.error('[Feedback] Failed to capture doctor example:', err.message);
1775
+ }
1776
+ })();
1777
+
1778
+ res.json({ success: true });
1779
+ } catch (error) {
1780
+ console.error('Send message error:', error);
1781
+ res.status(500).json({ error: 'Internal server error' });
1782
+ }
1783
+ });
1784
+
1785
+ app.patch('/api/doctor/thread/:threadId', express.json(), async (req, res) => {
1786
+ try {
1787
+ const user = await requireAuth(req, res);
1788
+ if (!user) return;
1789
+
1790
+ const providerDoc = await findProviderByOwner(user.uid);
1791
+ if (!providerDoc) {
1792
+ return res.status(404).json({ error: 'Provider not found' });
1793
+ }
1794
+
1795
+ const threadId = req.params.threadId;
1796
+
1797
+ const db = admin.firestore();
1798
+ const threadDoc = await db.collection('threads').doc(threadId).get();
1799
+
1800
+ if (!threadDoc.exists || threadDoc.data().doctorId !== providerDoc.id) {
1801
+ return res.status(404).json({ error: 'Thread not found' });
1802
+ }
1803
+
1804
+ const updates = {};
1805
+ const validStatuses = ['active', 'resolved', 'closed', 'archived'];
1806
+ if (req.body.status && validStatuses.includes(req.body.status)) updates.status = req.body.status;
1807
+ if (req.body.doctorOverride !== undefined) updates.doctorOverride = req.body.doctorOverride;
1808
+ if (req.body.isEscalated !== undefined) updates.isEscalated = req.body.isEscalated;
1809
+ // Allow manual triage override (Component 2)
1810
+ if (req.body.triageCategory) {
1811
+ const validCategories = ['urgent', 'needs_review', 'informational', 'resolved'];
1812
+ if (validCategories.includes(req.body.triageCategory)) {
1813
+ updates.triageCategory = req.body.triageCategory;
1814
+ updates.triageConfidence = 1.0;
1815
+ updates.triageReason = 'Manual override by doctor';
1816
+ updates.triageUpdatedAt = admin.firestore.FieldValue.serverTimestamp();
1817
+ }
1818
+ }
1819
+
1820
+ if (Object.keys(updates).length > 0) {
1821
+ updates.updatedAt = admin.firestore.FieldValue.serverTimestamp();
1822
+ await threadDoc.ref.update(updates);
1823
+ }
1824
+
1825
+ res.json({ success: true });
1826
+ } catch (error) {
1827
+ console.error('Update thread error:', error);
1828
+ res.status(500).json({ error: 'Internal server error' });
1829
+ }
1830
+ });
1831
+
1832
+ // Get conversations (legacy)
1833
+ app.get('/api/conversations', async (req, res) => {
1834
+ try {
1835
+ const user = await requireAuth(req, res);
1836
+ if (!user) return;
1837
+
1838
+ const providerDoc = await findProviderByOwner(user.uid);
1839
+ if (!providerDoc) {
1840
+ return res.status(404).json({ error: 'Provider not found' });
1841
+ }
1842
+
1843
+ const limit = Math.min(parseInt(req.query.limit, 10) || 30, 50);
1844
+ const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
1845
+ const filterEscalated = req.query.escalated;
1846
+ const filterTopic = req.query.topic;
1847
+ const filterSatisfaction = req.query.satisfaction;
1848
+ const search = (req.query.q || '').toLowerCase().trim();
1849
+ const db = admin.firestore();
1850
+ let query = db
1851
+ .collection('conversations')
1852
+ .where('providerId', '==', providerDoc.id);
1853
+
1854
+ if (filterEscalated === 'true') {
1855
+ query = query.where('isEscalated', '==', true);
1856
+ }
1857
+ if (filterTopic && TOPIC_KEYS.includes(filterTopic)) {
1858
+ query = query.where('topic', '==', filterTopic);
1859
+ }
1860
+
1861
+ query = query.orderBy('updatedAt', 'desc');
1862
+
1863
+ if (offset > 0) {
1864
+ const skipSnap = await query.limit(offset).get();
1865
+ if (!skipSnap.empty) {
1866
+ const lastDoc = skipSnap.docs[skipSnap.docs.length - 1];
1867
+ query = query.startAfter(lastDoc);
1868
+ }
1869
+ }
1870
+
1871
+ const snapshot = await query.limit(limit + 1).get();
1872
+ const hasMore = snapshot.docs.length > limit;
1873
+ const resultDocs = hasMore ? snapshot.docs.slice(0, limit) : snapshot.docs;
1874
+
1875
+ let conversations = resultDocs.map((doc) => ({ id: doc.id, ...doc.data() }));
1876
+
1877
+ if (search) {
1878
+ conversations = conversations.filter(c =>
1879
+ (c.lastUserMessage || '').toLowerCase().includes(search) ||
1880
+ (c.clientName || '').toLowerCase().includes(search)
1881
+ );
1882
+ }
1883
+
1884
+ if (filterSatisfaction === 'high') {
1885
+ conversations = conversations.filter(c => c.satisfactionRating >= 4);
1886
+ } else if (filterSatisfaction === 'low') {
1887
+ conversations = conversations.filter(c => c.satisfactionRating >= 1 && c.satisfactionRating <= 2);
1888
+ } else if (filterSatisfaction === 'none') {
1889
+ conversations = conversations.filter(c => !c.satisfactionRating);
1890
+ }
1891
+
1892
+ res.json({ conversations, hasMore, offset, limit });
1893
+ } catch (error) {
1894
+ console.error('Error getting conversations:', error);
1895
+ res.status(500).json({ error: 'Internal server error' });
1896
+ }
1897
+ });
1898
+
1899
+ // Get single conversation with messages (by ID in path)
1900
+ app.get('/api/conversation/:id', async (req, res) => {
1901
+ try {
1902
+ const user = await requireAuth(req, res);
1903
+ if (!user) return;
1904
+
1905
+ const providerDoc = await findProviderByOwner(user.uid);
1906
+ if (!providerDoc) {
1907
+ return res.status(404).json({ error: 'Provider not found' });
1908
+ }
1909
+
1910
+ const conversationId = String(req.params.id || '').trim();
1911
+ if (!conversationId) {
1912
+ return res.status(400).json({ error: 'Conversation id required' });
1913
+ }
1914
+
1915
+ const db = admin.firestore();
1916
+ const convoRef = db.collection('conversations').doc(conversationId);
1917
+ const convoDoc = await convoRef.get();
1918
+ if (!convoDoc.exists || convoDoc.data().providerId !== providerDoc.id) {
1919
+ return res.status(404).json({ error: 'Conversation not found' });
1920
+ }
1921
+
1922
+ const messagesSnapshot = await convoRef
1923
+ .collection('messages')
1924
+ .orderBy('createdAt', 'asc')
1925
+ .limit(100)
1926
+ .get();
1927
+
1928
+ const messages = messagesSnapshot.docs.map((doc) => ({
1929
+ role: doc.data().role,
1930
+ content: doc.data().content || doc.data().message,
1931
+ createdAt: doc.data().createdAt
1932
+ }));
1933
+
1934
+ const convoData = convoDoc.data();
1935
+ res.json({
1936
+ id: convoDoc.id,
1937
+ topic: convoData.topic || null,
1938
+ isEscalated: convoData.isEscalated || false,
1939
+ satisfaction: convoData.satisfaction || null,
1940
+ createdAt: convoData.createdAt,
1941
+ updatedAt: convoData.updatedAt,
1942
+ messages
1943
+ });
1944
+ } catch (error) {
1945
+ console.error('Error getting conversation:', error);
1946
+ res.status(500).json({ error: 'Internal server error' });
1947
+ }
1948
+ });
1949
+
1950
+ // Doctor replies to a conversation
1951
+ app.post('/api/conversation/:id/reply', express.json(), async (req, res) => {
1952
+ try {
1953
+ const user = await requireAuth(req, res);
1954
+ if (!user) return;
1955
+
1956
+ const providerDoc = await findProviderByOwner(user.uid);
1957
+ if (!providerDoc) {
1958
+ return res.status(404).json({ error: 'Provider not found' });
1959
+ }
1960
+
1961
+ const conversationId = String(req.params.id || '').trim();
1962
+ if (!conversationId) {
1963
+ return res.status(400).json({ error: 'Conversation id required' });
1964
+ }
1965
+
1966
+ const { message } = req.body;
1967
+ const trimmedMessage = String(message || '').trim();
1968
+ if (!trimmedMessage) {
1969
+ return res.status(400).json({ error: 'message is required' });
1970
+ }
1971
+ if (trimmedMessage.length > 10000) {
1972
+ return res.status(400).json({ error: 'Message exceeds maximum length of 10000 characters' });
1973
+ }
1974
+
1975
+ const db = admin.firestore();
1976
+ const convoRef = db.collection('conversations').doc(conversationId);
1977
+ const convoDoc = await convoRef.get();
1978
+
1979
+ if (!convoDoc.exists || convoDoc.data().providerId !== providerDoc.id) {
1980
+ return res.status(404).json({ error: 'Conversation not found' });
1981
+ }
1982
+
1983
+ // Add doctor's message to the conversation's messages subcollection
1984
+ await convoRef.collection('messages').add({
1985
+ role: 'doctor',
1986
+ content: trimmedMessage,
1987
+ isDoctorManual: true,
1988
+ doctorUserId: user.uid,
1989
+ createdAt: admin.firestore.FieldValue.serverTimestamp()
1990
+ });
1991
+
1992
+ // Update conversation metadata
1993
+ await convoRef.update({
1994
+ lastAssistantMessage: trimmedMessage,
1995
+ lastMessageAt: admin.firestore.FieldValue.serverTimestamp(),
1996
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
1997
+ });
1998
+
1999
+ res.json({ success: true });
2000
+ } catch (error) {
2001
+ console.error('Error sending doctor reply:', error);
2002
+ res.status(500).json({ error: 'Internal server error' });
2003
+ }
2004
+ });
2005
+
2006
+ // Get single conversation with messages (by ID in query - legacy)
2007
+ app.get('/api/conversation', async (req, res) => {
2008
+ try {
2009
+ const user = await requireAuth(req, res);
2010
+ if (!user) return;
2011
+
2012
+ const providerDoc = await findProviderByOwner(user.uid);
2013
+ if (!providerDoc) {
2014
+ return res.status(404).json({ error: 'Provider not found' });
2015
+ }
2016
+
2017
+ const conversationId = String(req.query.id || '').trim();
2018
+ if (!conversationId) {
2019
+ return res.status(400).json({ error: 'Conversation id required' });
2020
+ }
2021
+
2022
+ const db = admin.firestore();
2023
+ const convoRef = db.collection('conversations').doc(conversationId);
2024
+ const convoDoc = await convoRef.get();
2025
+ if (!convoDoc.exists || convoDoc.data().providerId !== providerDoc.id) {
2026
+ return res.status(404).json({ error: 'Conversation not found' });
2027
+ }
2028
+
2029
+ const messagesSnapshot = await convoRef
2030
+ .collection('messages')
2031
+ .orderBy('createdAt', 'asc')
2032
+ .limit(100)
2033
+ .get();
2034
+
2035
+ const messages = messagesSnapshot.docs.map((doc) => doc.data());
2036
+ res.json({ conversation: { id: convoDoc.id, ...convoDoc.data() }, messages });
2037
+ } catch (error) {
2038
+ console.error('Error getting conversation:', error);
2039
+ res.status(500).json({ error: 'Internal server error' });
2040
+ }
2041
+ });
2042
+
2043
+ // Get analytics
2044
+ app.get('/api/analytics', async (req, res) => {
2045
+ try {
2046
+ const user = await requireAuth(req, res);
2047
+ if (!user) return;
2048
+
2049
+ const providerDoc = await findProviderByOwner(user.uid);
2050
+ if (!providerDoc) {
2051
+ return res.status(404).json({ error: 'Provider not found' });
2052
+ }
2053
+
2054
+ const db = admin.firestore();
2055
+ const snapshot = await db
2056
+ .collection('providers')
2057
+ .doc(providerDoc.id)
2058
+ .collection('analyticsDaily')
2059
+ .orderBy('updatedAt', 'desc')
2060
+ .limit(14)
2061
+ .get();
2062
+
2063
+ const daily = snapshot.docs.map((doc) => ({ date: doc.id, ...doc.data() }));
2064
+
2065
+ const questionBuckets = {};
2066
+ const topicCounts = {};
2067
+ daily.forEach((entry) => {
2068
+ const buckets = entry.questionBuckets || {};
2069
+ Object.entries(buckets).forEach(([key, value]) => {
2070
+ questionBuckets[key] = (questionBuckets[key] || 0) + value;
2071
+ });
2072
+ const topics = entry.topicCounts || {};
2073
+ Object.entries(topics).forEach(([key, value]) => {
2074
+ topicCounts[key] = (topicCounts[key] || 0) + value;
2075
+ });
2076
+ });
2077
+
2078
+ const topQuestions = Object.entries(questionBuckets)
2079
+ .sort((a, b) => b[1] - a[1])
2080
+ .slice(0, 5)
2081
+ .map(([question, count]) => ({ question, count }));
2082
+
2083
+ const topTopics = Object.entries(topicCounts)
2084
+ .sort((a, b) => b[1] - a[1])
2085
+ .slice(0, 5)
2086
+ .map(([topic, count]) => ({ topic, count }));
2087
+
2088
+ let satisfactionSum = 0;
2089
+ let satisfactionCount = 0;
2090
+ let totalChatCount = 0;
2091
+ let totalEscalations = 0;
2092
+ const allSessions = new Set();
2093
+ daily.forEach((entry) => {
2094
+ satisfactionSum += entry.satisfactionSum || 0;
2095
+ satisfactionCount += entry.satisfactionCount || 0;
2096
+ totalChatCount += entry.chatCount || 0;
2097
+ totalEscalations += entry.escalationCount || 0;
2098
+ // Unique sessions: new writer stores a `sessions` map ({id:true});
2099
+ // keep array support for any legacy daily docs.
2100
+ if (entry.sessions && typeof entry.sessions === 'object') {
2101
+ Object.keys(entry.sessions).forEach(s => allSessions.add(s));
2102
+ } else if (Array.isArray(entry.uniqueSessions)) {
2103
+ entry.uniqueSessions.forEach(s => allSessions.add(s));
2104
+ }
2105
+ });
2106
+
2107
+ // Count total threads for this provider
2108
+ const threadsSnapshot = await db.collection('threads')
2109
+ .where('doctorId', '==', providerDoc.id)
2110
+ .select()
2111
+ .limit(1000)
2112
+ .get();
2113
+ const totalConversations = threadsSnapshot.size;
2114
+
2115
+ const providerData = providerDoc.data();
2116
+
2117
+ const escalationRate = totalChatCount > 0 ? Number(((totalEscalations / totalChatCount) * 100).toFixed(1)) : 0;
2118
+ const resolvedCount = (providerData.draftsAccepted || 0) + (providerData.draftsEdited || 0);
2119
+ const totalDrafts = resolvedCount + (providerData.draftsRejected || 0);
2120
+ const resolutionRate = totalDrafts > 0 ? Number(((resolvedCount / totalDrafts) * 100).toFixed(1)) : null;
2121
+
2122
+ res.json({
2123
+ provider: mapProviderPrivate({ id: providerDoc.id, ...providerData }),
2124
+ daily,
2125
+ topQuestions,
2126
+ topTopics,
2127
+ totalConversations,
2128
+ uniquePatients: allSessions.size,
2129
+ totalChatCount,
2130
+ totalEscalations,
2131
+ escalationRate,
2132
+ resolutionRate,
2133
+ satisfaction: {
2134
+ average: satisfactionCount > 0 ? (satisfactionSum / satisfactionCount).toFixed(1) : null,
2135
+ count: satisfactionCount
2136
+ },
2137
+ draftMetrics: {
2138
+ accepted: providerData.draftsAccepted || 0,
2139
+ edited: providerData.draftsEdited || 0,
2140
+ rejected: providerData.draftsRejected || 0
2141
+ }
2142
+ });
2143
+ } catch (error) {
2144
+ console.error('Error getting analytics:', error);
2145
+ res.status(500).json({ error: 'Internal server error' });
2146
+ }
2147
+ });
2148
+
2149
+ // Get safety events for provider
2150
+ app.get('/api/safety-events', async (req, res) => {
2151
+ try {
2152
+ const user = await requireAuth(req, res);
2153
+ if (!user) return;
2154
+ const providerDoc = await findProviderByOwner(user.uid);
2155
+ if (!providerDoc) return res.status(404).json({ error: 'Provider not found' });
2156
+
2157
+ const db = admin.firestore();
2158
+ const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
2159
+ const snapshot = await db.collection('safetyEvents')
2160
+ .where('providerId', '==', providerDoc.id)
2161
+ .orderBy('createdAt', 'desc')
2162
+ .limit(limit)
2163
+ .get();
2164
+
2165
+ const events = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
2166
+ res.json({ events });
2167
+ } catch (error) {
2168
+ console.error('Error getting safety events:', error);
2169
+ res.status(500).json({ error: 'Internal server error' });
2170
+ }
2171
+ });
2172
+
2173
+ // Submit satisfaction rating
2174
+ app.post('/api/satisfaction', rateLimit({ maxRequests: 10, windowMs: 60000 }), async (req, res) => {
2175
+ try {
2176
+ const body = req.body || {};
2177
+ const conversationId = String(body.conversationId || '').trim();
2178
+ const sessionId = String(body.sessionId || '').trim();
2179
+ const rating = Number(body.rating);
2180
+
2181
+ if (!conversationId || !sessionId || !Number.isFinite(rating) || rating < 1 || rating > 5) {
2182
+ return res.status(400).json({ error: 'conversationId, sessionId and rating (1-5) are required' });
2183
+ }
2184
+
2185
+ const db = admin.firestore();
2186
+
2187
+ // Ownership check: the rater must own the session tied to this conversation.
2188
+ // The chat flow returns a thread id; legacy flows use the conversations
2189
+ // collection. Verify against whichever exists — and never create a doc
2190
+ // (set/merge on a missing id would let anyone fabricate conversations).
2191
+ let targetRef = null;
2192
+ const threadDoc = await db.collection('threads').doc(conversationId).get();
2193
+ if (threadDoc.exists) {
2194
+ if (threadDoc.data().sessionId !== sessionId) {
2195
+ return res.status(403).json({ error: 'Forbidden' });
2196
+ }
2197
+ targetRef = threadDoc.ref;
2198
+ } else {
2199
+ const convoDoc = await db.collection('conversations').doc(conversationId).get();
2200
+ if (!convoDoc.exists) {
2201
+ return res.status(404).json({ error: 'Conversation not found' });
2202
+ }
2203
+ if (convoDoc.data().sessionId !== sessionId) {
2204
+ return res.status(403).json({ error: 'Forbidden' });
2205
+ }
2206
+ targetRef = convoDoc.ref;
2207
+ }
2208
+
2209
+ await targetRef.set({ satisfaction: rating, updatedAt: nowTimestamp() }, { merge: true });
2210
+
2211
+ res.json({ ok: true });
2212
+ } catch (error) {
2213
+ console.error('Error submitting satisfaction:', error);
2214
+ res.status(500).json({ error: 'Internal server error' });
2215
+ }
2216
+ });
2217
+
2218
+ // Get chat history for a patient session (reads from unified threads collection)
2219
+ app.get('/api/chat/history', rateLimit({ maxRequests: 30, windowMs: 60000 }), async (req, res) => {
2220
+ try {
2221
+ const handle = String(req.query.handle || '').trim();
2222
+ const sessionId = String(req.query.sessionId || '').trim();
2223
+ const limit = Math.min(parseInt(req.query.limit, 10) || 50, 100);
2224
+ const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
2225
+
2226
+ if (!handle || !sessionId) {
2227
+ return res.status(400).json({ error: 'handle and sessionId are required' });
2228
+ }
2229
+
2230
+ const db = admin.firestore();
2231
+
2232
+ // Look up provider by handle
2233
+ const handleDoc = await db.collection('handles').doc(handle).get();
2234
+ if (!handleDoc.exists) {
2235
+ return res.json({ messages: [], hasMore: false });
2236
+ }
2237
+ const providerId = handleDoc.data().providerId;
2238
+
2239
+ // Try unified thread first
2240
+ const threadId = `thread_anon_${sessionId}_${providerId}`;
2241
+ const threadDoc = await db.collection('threads').doc(threadId).get();
2242
+
2243
+ if (threadDoc.exists) {
2244
+ let msgQuery = threadDoc.ref
2245
+ .collection('messages')
2246
+ .orderBy('createdAt', 'asc');
2247
+
2248
+ if (offset > 0) {
2249
+ const skipSnap = await msgQuery.limit(offset).get();
2250
+ if (!skipSnap.empty) {
2251
+ msgQuery = msgQuery.startAfter(skipSnap.docs[skipSnap.docs.length - 1]);
2252
+ }
2253
+ }
2254
+
2255
+ const messagesSnapshot = await msgQuery.limit(limit + 1).get();
2256
+ const hasMore = messagesSnapshot.docs.length > limit;
2257
+ const docs = hasMore ? messagesSnapshot.docs.slice(0, limit) : messagesSnapshot.docs;
2258
+
2259
+ const messages = docs.map(doc => {
2260
+ const data = doc.data();
2261
+ return {
2262
+ role: data.role === 'patient' ? 'user' : (data.role === 'ai' ? 'assistant' : data.role),
2263
+ content: data.content || data.message,
2264
+ createdAt: data.createdAt
2265
+ };
2266
+ });
2267
+
2268
+ return res.json({ conversationId: threadId, messages, hasMore });
2269
+ }
2270
+
2271
+ // Fallback: check legacy conversations collection
2272
+ const convosSnapshot = await db.collection('conversations')
2273
+ .where('providerId', '==', providerId)
2274
+ .where('sessionId', '==', sessionId)
2275
+ .limit(5)
2276
+ .get();
2277
+
2278
+ if (convosSnapshot.empty) {
2279
+ return res.json({ messages: [], hasMore: false });
2280
+ }
2281
+
2282
+ const convoDoc = convosSnapshot.docs.sort((a, b) => {
2283
+ const aTime = a.data().lastMessageAt?._seconds || 0;
2284
+ const bTime = b.data().lastMessageAt?._seconds || 0;
2285
+ return bTime - aTime;
2286
+ })[0];
2287
+
2288
+ let msgQuery = convoDoc.ref
2289
+ .collection('messages')
2290
+ .orderBy('createdAt', 'asc');
2291
+
2292
+ if (offset > 0) {
2293
+ const skipSnap = await msgQuery.limit(offset).get();
2294
+ if (!skipSnap.empty) {
2295
+ msgQuery = msgQuery.startAfter(skipSnap.docs[skipSnap.docs.length - 1]);
2296
+ }
2297
+ }
2298
+
2299
+ const messagesSnapshot = await msgQuery.limit(limit + 1).get();
2300
+ const hasMore = messagesSnapshot.docs.length > limit;
2301
+ const docs = hasMore ? messagesSnapshot.docs.slice(0, limit) : messagesSnapshot.docs;
2302
+
2303
+ const messages = docs.map(doc => ({
2304
+ role: doc.data().role,
2305
+ content: doc.data().content,
2306
+ createdAt: doc.data().createdAt
2307
+ }));
2308
+
2309
+ res.json({ conversationId: convoDoc.id, messages, hasMore });
2310
+ } catch (error) {
2311
+ console.error('Error fetching chat history:', error);
2312
+ res.status(500).json({ error: 'Internal server error' });
2313
+ }
2314
+ });
2315
+
2316
+ // Chat endpoint (main conversation handler) - Unified model: writes to threads collection
2317
+ app.post('/api/chat', rateLimit({ maxRequests: 20, windowMs: 60000 }), async (req, res) => {
2318
+ try {
2319
+ const body = req.body || {};
2320
+ const handle = normalizeHandle(body.handle || '');
2321
+ const message = String(body.message || '').trim();
2322
+ const sessionId = String(body.sessionId || '').trim();
2323
+ const requestedTopic = body.topic ? String(body.topic) : null;
2324
+ // Strip HTML from patient-supplied identity fields — they render in the
2325
+ // authenticated doctor console, so raw markup would be a stored-XSS vector.
2326
+ const clientName = body.clientName ? stripHtml(String(body.clientName)).substring(0, 120) : null;
2327
+ const clientPhone = body.clientPhone ? stripHtml(String(body.clientPhone)).substring(0, 40) : null;
2328
+ let clientEmail = body.clientEmail ? stripHtml(String(body.clientEmail)).trim().toLowerCase() : null;
2329
+ if (clientEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clientEmail)) clientEmail = null;
2330
+
2331
+ if (!handle || !message || !sessionId) {
2332
+ return res.status(400).json({ error: 'handle, sessionId, and message are required' });
2333
+ }
2334
+ if (message.length > 5000) {
2335
+ return res.status(400).json({ error: 'Message too long (max 5000 characters)' });
2336
+ }
2337
+
2338
+ const MAX_MESSAGE_LENGTH = 5000;
2339
+ if (message.length > MAX_MESSAGE_LENGTH) {
2340
+ return res.status(400).json({ error: `Message too long. Maximum ${MAX_MESSAGE_LENGTH} characters.` });
2341
+ }
2342
+
2343
+ const providerDoc = await findProviderByHandle(handle);
2344
+ if (!providerDoc) {
2345
+ return res.status(404).json({ error: 'Provider not found' });
2346
+ }
2347
+
2348
+ const provider = providerDoc.data();
2349
+ if (provider.status === 'suspended') {
2350
+ return res.status(403).json({ error: 'This provider has been suspended and is not accepting chats at this time.' });
2351
+ }
2352
+ if (provider.status !== 'published' && provider.status !== 'preview') {
2353
+ return res.status(403).json({ error: 'This provider is not currently accepting chats. Please check back later.' });
2354
+ }
2355
+
2356
+ // Check monthly limit FIRST — clearer error than payment required
2357
+ const limitCheck = await checkMonthlyLimit({ providerDoc });
2358
+ if (!limitCheck.allowed) {
2359
+ return res.status(429).json({ error: limitCheck.message });
2360
+ }
2361
+
2362
+ if (limitCheck.resetNeeded) {
2363
+ const db0 = admin.firestore();
2364
+ await db0.runTransaction(async (tx) => {
2365
+ const freshDoc = await tx.get(providerDoc.ref);
2366
+ const freshData = freshDoc.data();
2367
+ if (freshData.monthlyChatCountMonth !== limitCheck.currentMonth) {
2368
+ tx.update(providerDoc.ref, {
2369
+ monthlyChatCount: 0,
2370
+ monthlyChatCountMonth: limitCheck.currentMonth,
2371
+ updatedAt: nowTimestamp()
2372
+ });
2373
+ }
2374
+ });
2375
+ }
2376
+
2377
+ // Payment access check (after limit check)
2378
+ if (provider.paymentEnabled && provider.pricingModel !== 'free') {
2379
+ const accessResult = await checkPaymentAccess({
2380
+ providerId: providerDoc.id,
2381
+ patientId: sessionId,
2382
+ conversationId: body.conversationId || null
2383
+ });
2384
+
2385
+ if (!accessResult.hasAccess) {
2386
+ return res.status(402).json({
2387
+ error: 'Payment required',
2388
+ paymentRequired: true,
2389
+ paymentConfig: {
2390
+ pricingModel: provider.pricingModel,
2391
+ subscriptionPrice: provider.subscriptionPrice || 0,
2392
+ perChatPrice: provider.perChatPrice || 0,
2393
+ currency: provider.currency || 'usd',
2394
+ handle,
2395
+ stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY
2396
+ }
2397
+ });
2398
+ }
2399
+ }
2400
+
2401
+ const enabledTopics = Array.isArray(provider.enabledTopics)
2402
+ ? provider.enabledTopics
2403
+ : TOPIC_KEYS;
2404
+ const topicKey = resolveTopic({
2405
+ requestedTopic,
2406
+ enabledTopics,
2407
+ message
2408
+ });
2409
+ const topicConfig = TOPICS[topicKey] || TOPICS[TOPIC_KEYS[0]];
2410
+
2411
+ const inputSafety = await analyzeInput(message, topicConfig);
2412
+ const db = admin.firestore();
2413
+
2414
+ // Unified model: use threads collection for all conversations (anonymous + authenticated)
2415
+ const threadId = `thread_anon_${sessionId}_${providerDoc.id}`;
2416
+ const threadRef = db.collection('threads').doc(threadId);
2417
+ const threadDoc = await threadRef.get();
2418
+
2419
+ if (!threadDoc.exists) {
2420
+ // Create new thread for anonymous patient
2421
+ const threadData = {
2422
+ patientId: `anon_${sessionId}`,
2423
+ patientEmail: clientEmail || null,
2424
+ patientName: clientName || null,
2425
+ patientPhoto: null,
2426
+ patientSource: 'anonymous',
2427
+ sessionId,
2428
+ clientPhone: clientPhone || null,
2429
+
2430
+ doctorId: providerDoc.id,
2431
+ doctorName: provider.displayName,
2432
+ doctorHandle: handle,
2433
+
2434
+ status: 'active',
2435
+ isEscalated: false,
2436
+ doctorOverride: false,
2437
+
2438
+ lastMessageAt: nowTimestamp(),
2439
+ lastMessageText: null,
2440
+ lastMessageRole: null,
2441
+
2442
+ unreadByPatient: 0,
2443
+ unreadByDoctor: 0,
2444
+ messageCount: 0,
2445
+
2446
+ currentTopic: topicKey,
2447
+
2448
+ isPaid: false,
2449
+ paymentId: null,
2450
+
2451
+ // Phase 2 fields
2452
+ triageCategory: null,
2453
+ triageConfidence: null,
2454
+ triageReason: null,
2455
+ triageUpdatedAt: null,
2456
+
2457
+ aiDraftResponse: null,
2458
+ aiDraftGeneratedAt: null,
2459
+ aiDraftStatus: null,
2460
+
2461
+ lastDoctorNotifiedAt: null,
2462
+ doctorResponseFedBack: false,
2463
+ feedbackProcessedAt: null,
2464
+
2465
+ createdAt: nowTimestamp(),
2466
+ updatedAt: nowTimestamp()
2467
+ };
2468
+
2469
+ await threadRef.set(threadData);
2470
+ } else {
2471
+ // Update existing thread with client contact info if provided
2472
+ const updateData = { updatedAt: nowTimestamp() };
2473
+ const existingData = threadDoc.data();
2474
+
2475
+ if (clientName && !existingData.patientName) updateData.patientName = clientName;
2476
+ if (clientEmail && !existingData.patientEmail) updateData.patientEmail = clientEmail;
2477
+ if (clientPhone && !existingData.clientPhone) updateData.clientPhone = clientPhone;
2478
+ if (topicKey) updateData.currentTopic = topicKey;
2479
+
2480
+ await threadRef.update(updateData);
2481
+ }
2482
+
2483
+ const messagesRef = threadRef.collection('messages');
2484
+ const redactedInput = redactText(message);
2485
+
2486
+ // Fetch conversation history (last 10 messages) for multi-turn context
2487
+ const historySnapshot = await messagesRef
2488
+ .orderBy('createdAt', 'asc')
2489
+ .limitToLast(10)
2490
+ .get();
2491
+
2492
+ const conversationHistory = historySnapshot.docs.map(doc => {
2493
+ const data = doc.data();
2494
+ return {
2495
+ role: (data.role === 'user' || data.role === 'patient') ? 'user' : 'assistant',
2496
+ content: data.content || data.message
2497
+ };
2498
+ });
2499
+
2500
+ // Save patient message
2501
+ await messagesRef.add({
2502
+ threadId,
2503
+ role: 'patient',
2504
+ message: redactedInput,
2505
+ content: redactedInput,
2506
+ safetyFlags: inputSafety.flags,
2507
+ readByPatient: true,
2508
+ readByDoctor: false,
2509
+ isAiGenerated: false,
2510
+ createdAt: nowTimestamp()
2511
+ });
2512
+
2513
+ let responseText = '';
2514
+ let escalated = inputSafety.escalated;
2515
+ let safetyFlags = [...inputSafety.flags];
2516
+
2517
+ if (inputSafety.blocked) {
2518
+ responseText = refusalMessage();
2519
+ safetyFlags.push('blocked_input');
2520
+ } else {
2521
+ // Retrieve doctor examples for personalized responses (Component 5)
2522
+ let doctorExamples = [];
2523
+ try {
2524
+ doctorExamples = await retrieveRelevantExamples({
2525
+ doctorId: providerDoc.id,
2526
+ topic: topicKey,
2527
+ limit: 3
2528
+ });
2529
+ } catch (err) {
2530
+ console.error('Failed to retrieve doctor examples:', err.message);
2531
+ }
2532
+
2533
+ // Generate LLM response with conversation history + doctor examples
2534
+ responseText = await generateResponse({
2535
+ topicConfig, message, provider, conversationHistory, doctorExamples
2536
+ });
2537
+ const outputSafety = analyzeOutput(responseText, topicConfig);
2538
+ if (outputSafety.blocked) {
2539
+ responseText = refusalMessage();
2540
+ safetyFlags = safetyFlags.concat(outputSafety.flags);
2541
+ }
2542
+
2543
+ // Add urgent care notice if escalated
2544
+ if (inputSafety.escalated) {
2545
+ responseText = `āš ļø URGENT: ${escalationMessage()}\n\n${responseText}`;
2546
+ safetyFlags.push('escalated');
2547
+ }
2548
+ }
2549
+
2550
+ responseText = `${responseText} ${topicConfig.disclaimer}`.trim();
2551
+ const redactedOutput = redactText(responseText);
2552
+
2553
+ // Save AI response
2554
+ await messagesRef.add({
2555
+ threadId,
2556
+ role: 'ai',
2557
+ message: redactedOutput,
2558
+ content: redactedOutput,
2559
+ safetyFlags,
2560
+ readByPatient: true,
2561
+ readByDoctor: false,
2562
+ isAiGenerated: true,
2563
+ createdAt: nowTimestamp()
2564
+ });
2565
+
2566
+ // Safety events
2567
+ if (inputSafety.flags.length) {
2568
+ await db.collection('safetyEvents').add({
2569
+ providerId: providerDoc.id,
2570
+ threadId,
2571
+ eventType: inputSafety.blocked ? 'blocked_input' : 'pii_detected',
2572
+ details: { flags: inputSafety.flags },
2573
+ createdAt: nowTimestamp()
2574
+ });
2575
+ }
2576
+
2577
+ if (safetyFlags.includes('blocked_output')) {
2578
+ await db.collection('safetyEvents').add({
2579
+ providerId: providerDoc.id,
2580
+ threadId,
2581
+ eventType: 'blocked_output',
2582
+ details: { flags: safetyFlags },
2583
+ createdAt: nowTimestamp()
2584
+ });
2585
+ }
2586
+
2587
+ if (escalated) {
2588
+ await db.collection('safetyEvents').add({
2589
+ providerId: providerDoc.id,
2590
+ threadId,
2591
+ eventType: 'escalated',
2592
+ details: { reason: 'red_flag' },
2593
+ createdAt: nowTimestamp()
2594
+ });
2595
+ }
2596
+
2597
+ // Update thread metadata
2598
+ const threadUpdate = {
2599
+ isEscalated: escalated || false,
2600
+ lastMessageAt: nowTimestamp(),
2601
+ lastMessageText: redactedInput.substring(0, 100),
2602
+ lastMessageRole: 'ai',
2603
+ messageCount: admin.firestore.FieldValue.increment(2),
2604
+ unreadByDoctor: admin.firestore.FieldValue.increment(1),
2605
+ updatedAt: nowTimestamp()
2606
+ };
2607
+ if (escalated) {
2608
+ threadUpdate.escalationReason = inputSafety.blocked ? 'blocked_input' : 'red_flag';
2609
+ }
2610
+ await threadRef.update(threadUpdate);
2611
+
2612
+ // Fire-and-forget: Triage classification (Component 2)
2613
+ const allMessages = [
2614
+ ...conversationHistory,
2615
+ { role: 'user', content: redactedInput },
2616
+ { role: 'assistant', content: redactedOutput }
2617
+ ];
2618
+
2619
+ classifyConversation({ messages: allMessages, isEscalated: escalated })
2620
+ .then(async (triage) => {
2621
+ console.log(`[Triage] Thread ${threadId}: ${triage.category} (${triage.confidence})`);
2622
+ const triageUpdate = {
2623
+ triageCategory: triage.category,
2624
+ triageConfidence: triage.confidence,
2625
+ triageReason: triage.reason,
2626
+ triageUpdatedAt: admin.firestore.FieldValue.serverTimestamp()
2627
+ };
2628
+ await threadRef.update(triageUpdate);
2629
+
2630
+ // If urgent or needs_review, generate doctor draft (Component 3)
2631
+ if (triage.category === 'urgent' || triage.category === 'needs_review') {
2632
+ try {
2633
+ let draftExamples = [];
2634
+ try {
2635
+ draftExamples = await retrieveRelevantExamples({
2636
+ doctorId: providerDoc.id,
2637
+ topic: topicKey,
2638
+ limit: 3
2639
+ });
2640
+ } catch (e) { /* ignore */ }
2641
+
2642
+ const draftResult = await generateDoctorDraft({
2643
+ messages: allMessages,
2644
+ provider,
2645
+ topic: topicKey,
2646
+ triageCategory: triage.category,
2647
+ doctorExamples: draftExamples
2648
+ });
2649
+
2650
+ if (draftResult) {
2651
+ await threadRef.update({
2652
+ aiDraftResponse: draftResult.draft,
2653
+ aiDraftGeneratedAt: admin.firestore.FieldValue.serverTimestamp(),
2654
+ aiDraftStatus: 'pending'
2655
+ });
2656
+ console.log(`[Drafts] Generated draft for thread ${threadId}`);
2657
+
2658
+ // Notify doctor about draft (Component 4)
2659
+ notifyDraftReady({
2660
+ doctorId: providerDoc.id,
2661
+ threadId,
2662
+ patientName: clientName
2663
+ }).catch(err => console.error('[Notifications] Draft notification failed:', err.message));
2664
+ }
2665
+ } catch (draftErr) {
2666
+ console.error('[Drafts] Draft generation failed:', draftErr.message);
2667
+ }
2668
+
2669
+ // Notify escalation (Component 4)
2670
+ if (triage.category === 'urgent') {
2671
+ notifyEscalation({
2672
+ doctorId: providerDoc.id,
2673
+ threadId,
2674
+ patientName: clientName,
2675
+ reason: triage.reason
2676
+ }).catch(err => console.error('[Notifications] Escalation notification failed:', err.message));
2677
+ }
2678
+ }
2679
+ })
2680
+ .catch(err => console.error('[Triage] Classification failed:', err.message));
2681
+
2682
+ // Increment provider monthly chat count (critical for billing/limit enforcement)
2683
+ try {
2684
+ await providerDoc.ref.update({
2685
+ monthlyChatCount: admin.firestore.FieldValue.increment(1),
2686
+ updatedAt: nowTimestamp()
2687
+ });
2688
+ } catch (countErr) {
2689
+ console.error('Monthly count increment failed:', countErr.message);
2690
+ }
2691
+
2692
+ // Analytics
2693
+ try {
2694
+ await incrementAnalytics({
2695
+ providerId: providerDoc.id,
2696
+ sessionId,
2697
+ escalated,
2698
+ satisfaction: null,
2699
+ topicKey,
2700
+ message: redactedInput
2701
+ });
2702
+ } catch (analyticsErr) {
2703
+ console.error('Analytics update failed:', analyticsErr.message);
2704
+ }
2705
+
2706
+ // Audit log — record PHI access
2707
+ logAuditEvent({
2708
+ action: 'chat_message',
2709
+ actorId: sessionId,
2710
+ actorType: 'patient',
2711
+ resourceType: 'thread',
2712
+ resourceId: threadId,
2713
+ meta: { providerId: providerDoc.id, escalated, topicKey }
2714
+ }).catch(() => {});
2715
+
2716
+ res.json({
2717
+ conversationId: threadId,
2718
+ response: redactedOutput,
2719
+ escalation: escalated,
2720
+ safetyFlags,
2721
+ topic: topicKey
2722
+ });
2723
+ } catch (error) {
2724
+ console.error('Error in chat:', error);
2725
+ res.status(500).json({ error: 'Internal server error' });
2726
+ }
2727
+ });
2728
+
2729
+ // ============================================
2730
+ // PHASE 2: DOCTOR ASYNC RESPONSE PIPELINE ENDPOINTS
2731
+ // ============================================
2732
+
2733
+ // --- Component 3: AI Draft Management ---
2734
+
2735
+ // Accept AI draft (optionally with edits)
2736
+ app.post('/api/doctor/draft/accept', express.json(), async (req, res) => {
2737
+ try {
2738
+ const user = await requireAuth(req, res);
2739
+ if (!user) return;
2740
+
2741
+ const providerDoc = await findProviderByOwner(user.uid);
2742
+ if (!providerDoc) {
2743
+ return res.status(404).json({ error: 'Provider not found' });
2744
+ }
2745
+
2746
+ const { threadId, editedMessage } = req.body;
2747
+ if (!threadId) {
2748
+ return res.status(400).json({ error: 'threadId is required' });
2749
+ }
2750
+
2751
+ const db = admin.firestore();
2752
+ const threadDoc = await db.collection('threads').doc(threadId).get();
2753
+
2754
+ if (!threadDoc.exists || threadDoc.data().doctorId !== providerDoc.id) {
2755
+ return res.status(404).json({ error: 'Thread not found' });
2756
+ }
2757
+
2758
+ const threadData = threadDoc.data();
2759
+ if (!threadData.aiDraftResponse) {
2760
+ return res.status(400).json({ error: 'No draft available' });
2761
+ }
2762
+
2763
+ // Use edited message or original draft
2764
+ const finalMessage = editedMessage ? String(editedMessage).trim() : threadData.aiDraftResponse;
2765
+
2766
+ // Add as doctor message
2767
+ await threadDoc.ref.collection('messages').add({
2768
+ threadId,
2769
+ role: 'doctor',
2770
+ message: finalMessage,
2771
+ isDoctorManual: false,
2772
+ doctorUserId: user.uid,
2773
+ readByPatient: false,
2774
+ readByDoctor: true,
2775
+ wasFiltered: false,
2776
+ isAiGenerated: true,
2777
+ aiDraftAccepted: true,
2778
+ createdAt: admin.firestore.FieldValue.serverTimestamp()
2779
+ });
2780
+
2781
+ const draftAction = editedMessage ? 'edited' : 'accepted';
2782
+ await threadDoc.ref.update({
2783
+ aiDraftStatus: draftAction,
2784
+ lastMessageAt: admin.firestore.FieldValue.serverTimestamp(),
2785
+ lastMessageText: finalMessage.substring(0, 100),
2786
+ lastMessageRole: 'doctor',
2787
+ messageCount: admin.firestore.FieldValue.increment(1),
2788
+ unreadByPatient: admin.firestore.FieldValue.increment(1),
2789
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
2790
+ });
2791
+
2792
+ // Track draft acceptance metrics on provider
2793
+ const draftMetricField = draftAction === 'accepted' ? 'draftsAccepted' : 'draftsEdited';
2794
+ providerDoc.ref.update({
2795
+ [draftMetricField]: admin.firestore.FieldValue.increment(1)
2796
+ }).catch(() => {});
2797
+
2798
+ // Capture as feedback (Component 5)
2799
+ (async () => {
2800
+ try {
2801
+ const recentMessages = await threadDoc.ref.collection('messages')
2802
+ .orderBy('createdAt', 'desc')
2803
+ .limit(5)
2804
+ .get();
2805
+
2806
+ const patientMsg = recentMessages.docs
2807
+ .map(d => d.data())
2808
+ .find(m => m.role === 'patient' || m.role === 'user');
2809
+
2810
+ if (patientMsg) {
2811
+ await captureDoctorExample({
2812
+ doctorId: providerDoc.id,
2813
+ threadId,
2814
+ topic: threadData.currentTopic,
2815
+ patientMessage: patientMsg.message || patientMsg.content,
2816
+ doctorResponse: finalMessage,
2817
+ wasAiDraft: true,
2818
+ aiDraftOriginal: threadData.aiDraftResponse
2819
+ });
2820
+ }
2821
+ } catch (err) {
2822
+ console.error('[Feedback] Failed to capture draft acceptance:', err.message);
2823
+ }
2824
+ })();
2825
+
2826
+ logAuditEvent({
2827
+ action: editedMessage ? 'draft_edited' : 'draft_accepted',
2828
+ actorId: user.uid,
2829
+ actorType: 'doctor',
2830
+ resourceType: 'thread',
2831
+ resourceId: threadId,
2832
+ meta: { providerId: providerDoc.id }
2833
+ }).catch(() => {});
2834
+
2835
+ res.json({ success: true });
2836
+ } catch (error) {
2837
+ console.error('Accept draft error:', error);
2838
+ res.status(500).json({ error: 'Internal server error' });
2839
+ }
2840
+ });
2841
+
2842
+ // Reject AI draft
2843
+ app.post('/api/doctor/draft/reject', express.json(), async (req, res) => {
2844
+ try {
2845
+ const user = await requireAuth(req, res);
2846
+ if (!user) return;
2847
+
2848
+ const providerDoc = await findProviderByOwner(user.uid);
2849
+ if (!providerDoc) {
2850
+ return res.status(404).json({ error: 'Provider not found' });
2851
+ }
2852
+
2853
+ const { threadId } = req.body;
2854
+ if (!threadId) {
2855
+ return res.status(400).json({ error: 'threadId is required' });
2856
+ }
2857
+
2858
+ const db = admin.firestore();
2859
+ const threadDoc = await db.collection('threads').doc(threadId).get();
2860
+
2861
+ if (!threadDoc.exists || threadDoc.data().doctorId !== providerDoc.id) {
2862
+ return res.status(404).json({ error: 'Thread not found' });
2863
+ }
2864
+
2865
+ await threadDoc.ref.update({
2866
+ aiDraftStatus: 'rejected',
2867
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
2868
+ });
2869
+
2870
+ providerDoc.ref.update({
2871
+ draftsRejected: admin.firestore.FieldValue.increment(1)
2872
+ }).catch(() => {});
2873
+
2874
+ logAuditEvent({
2875
+ action: 'draft_rejected',
2876
+ actorId: user.uid,
2877
+ actorType: 'doctor',
2878
+ resourceType: 'thread',
2879
+ resourceId: threadId,
2880
+ meta: { providerId: providerDoc.id }
2881
+ }).catch(() => {});
2882
+
2883
+ res.json({ success: true });
2884
+ } catch (error) {
2885
+ console.error('Reject draft error:', error);
2886
+ res.status(500).json({ error: 'Internal server error' });
2887
+ }
2888
+ });
2889
+
2890
+ // Regenerate AI draft
2891
+ app.post('/api/doctor/draft/regenerate', express.json(), async (req, res) => {
2892
+ try {
2893
+ const user = await requireAuth(req, res);
2894
+ if (!user) return;
2895
+
2896
+ const providerDoc = await findProviderByOwner(user.uid);
2897
+ if (!providerDoc) {
2898
+ return res.status(404).json({ error: 'Provider not found' });
2899
+ }
2900
+
2901
+ const { threadId } = req.body;
2902
+ if (!threadId) {
2903
+ return res.status(400).json({ error: 'threadId is required' });
2904
+ }
2905
+
2906
+ const db = admin.firestore();
2907
+ const threadDoc = await db.collection('threads').doc(threadId).get();
2908
+
2909
+ if (!threadDoc.exists || threadDoc.data().doctorId !== providerDoc.id) {
2910
+ return res.status(404).json({ error: 'Thread not found' });
2911
+ }
2912
+
2913
+ const threadData = threadDoc.data();
2914
+ const provider = providerDoc.data();
2915
+
2916
+ // Get thread messages for context
2917
+ const messagesSnapshot = await threadDoc.ref.collection('messages')
2918
+ .orderBy('createdAt', 'asc')
2919
+ .limit(20)
2920
+ .get();
2921
+
2922
+ const messages = messagesSnapshot.docs.map(doc => {
2923
+ const data = doc.data();
2924
+ return {
2925
+ role: (data.role === 'patient' || data.role === 'user') ? 'user' : 'assistant',
2926
+ content: data.content || data.message
2927
+ };
2928
+ });
2929
+
2930
+ // Get doctor examples
2931
+ let doctorExamples = [];
2932
+ try {
2933
+ doctorExamples = await retrieveRelevantExamples({
2934
+ doctorId: providerDoc.id,
2935
+ topic: threadData.currentTopic,
2936
+ limit: 3
2937
+ });
2938
+ } catch (e) { /* ignore */ }
2939
+
2940
+ const draftResult = await generateDoctorDraft({
2941
+ messages,
2942
+ provider,
2943
+ topic: threadData.currentTopic,
2944
+ triageCategory: threadData.triageCategory || 'needs_review',
2945
+ doctorExamples
2946
+ });
2947
+
2948
+ if (!draftResult) {
2949
+ return res.status(500).json({ error: 'Failed to generate draft' });
2950
+ }
2951
+
2952
+ await threadDoc.ref.update({
2953
+ aiDraftResponse: draftResult.draft,
2954
+ aiDraftGeneratedAt: admin.firestore.FieldValue.serverTimestamp(),
2955
+ aiDraftStatus: 'pending',
2956
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
2957
+ });
2958
+
2959
+ logAuditEvent({
2960
+ action: 'draft_regenerated',
2961
+ actorId: user.uid,
2962
+ actorType: 'doctor',
2963
+ resourceType: 'thread',
2964
+ resourceId: threadId,
2965
+ meta: { providerId: providerDoc.id }
2966
+ }).catch(() => {});
2967
+
2968
+ res.json({ success: true, draft: draftResult.draft });
2969
+ } catch (error) {
2970
+ console.error('Regenerate draft error:', error);
2971
+ res.status(500).json({ error: 'Internal server error' });
2972
+ }
2973
+ });
2974
+
2975
+ // --- Component 4: Notification Endpoints ---
2976
+
2977
+ // Get notifications
2978
+ app.get('/api/doctor/notifications', async (req, res) => {
2979
+ try {
2980
+ const user = await requireAuth(req, res);
2981
+ if (!user) return;
2982
+
2983
+ const providerDoc = await findProviderByOwner(user.uid);
2984
+ if (!providerDoc) {
2985
+ return res.status(404).json({ error: 'Provider not found' });
2986
+ }
2987
+
2988
+ const limit = Math.min(parseInt(req.query.limit, 10) || 20, 50);
2989
+ const notifications = await getNotifications({ doctorId: providerDoc.id, limit });
2990
+ const unreadCount = await getUnreadCount({ doctorId: providerDoc.id });
2991
+
2992
+ res.json({ notifications, unreadCount });
2993
+ } catch (error) {
2994
+ console.error('Get notifications error:', error);
2995
+ res.status(500).json({ error: 'Internal server error' });
2996
+ }
2997
+ });
2998
+
2999
+ // Mark notification as read
3000
+ app.post('/api/doctor/notifications/read', express.json(), async (req, res) => {
3001
+ try {
3002
+ const user = await requireAuth(req, res);
3003
+ if (!user) return;
3004
+
3005
+ const providerDoc = await findProviderByOwner(user.uid);
3006
+ if (!providerDoc) {
3007
+ return res.status(404).json({ error: 'Provider not found' });
3008
+ }
3009
+
3010
+ const { notificationId } = req.body;
3011
+
3012
+ if (notificationId) {
3013
+ await markNotificationRead({ notificationId, doctorId: providerDoc.id });
3014
+ } else {
3015
+ await markAllNotificationsRead({ doctorId: providerDoc.id });
3016
+ }
3017
+
3018
+ res.json({ success: true });
3019
+ } catch (error) {
3020
+ console.error('Mark notification read error:', error);
3021
+ res.status(500).json({ error: 'Internal server error' });
3022
+ }
3023
+ });
3024
+
3025
+ // Update notification preferences
3026
+ app.post('/api/doctor/notifications/preferences', express.json(), async (req, res) => {
3027
+ try {
3028
+ const user = await requireAuth(req, res);
3029
+ if (!user) return;
3030
+
3031
+ const providerDoc = await findProviderByOwner(user.uid);
3032
+ if (!providerDoc) {
3033
+ return res.status(404).json({ error: 'Provider not found' });
3034
+ }
3035
+
3036
+ const prefs = req.body || {};
3037
+ const validPrefs = {};
3038
+
3039
+ if (typeof prefs.emailEnabled === 'boolean') validPrefs.emailEnabled = prefs.emailEnabled;
3040
+ if (typeof prefs.pushEnabled === 'boolean') validPrefs.pushEnabled = prefs.pushEnabled;
3041
+ if (typeof prefs.escalationNotify === 'boolean') validPrefs.escalationNotify = prefs.escalationNotify;
3042
+ if (typeof prefs.unreadNotify === 'boolean') validPrefs.unreadNotify = prefs.unreadNotify;
3043
+ if (typeof prefs.dailyDigestEnabled === 'boolean') validPrefs.dailyDigestEnabled = prefs.dailyDigestEnabled;
3044
+ if (typeof prefs.unreadDelayMinutes === 'number') validPrefs.unreadDelayMinutes = Math.max(1, Math.min(60, prefs.unreadDelayMinutes));
3045
+ if (typeof prefs.email === 'string') validPrefs.email = prefs.email.trim();
3046
+
3047
+ await providerDoc.ref.update({
3048
+ notificationPreferences: validPrefs,
3049
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
3050
+ });
3051
+
3052
+ logAuditEvent({
3053
+ action: 'notification_preferences_updated',
3054
+ actorId: user.uid,
3055
+ actorType: 'doctor',
3056
+ resourceType: 'provider',
3057
+ resourceId: providerDoc.id,
3058
+ meta: { preferences: validPrefs }
3059
+ }).catch(() => {});
3060
+
3061
+ res.json({ success: true, preferences: validPrefs });
3062
+ } catch (error) {
3063
+ console.error('Update notification preferences error:', error);
3064
+ res.status(500).json({ error: 'Internal server error' });
3065
+ }
3066
+ });
3067
+
3068
+ // Trigger daily digest (can be called by cron/scheduler)
3069
+ app.post('/api/internal/daily-digest', rateLimit({ maxRequests: 5, windowMs: 3600000 }), express.json(), async (req, res) => {
3070
+ try {
3071
+ // Require INTERNAL_API_KEY — deny access if not configured or mismatch.
3072
+ // Use a constant-time comparison to avoid leaking the key via timing.
3073
+ const apiKey = String(req.headers['x-api-key'] || '');
3074
+ const expected = String(process.env.INTERNAL_API_KEY || '');
3075
+ const valid = expected.length > 0 &&
3076
+ apiKey.length === expected.length &&
3077
+ crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(expected));
3078
+ if (!valid) {
3079
+ return res.status(401).json({ error: 'Unauthorized' });
3080
+ }
3081
+
3082
+ const db = admin.firestore();
3083
+ const providersSnapshot = await db.collection('providers')
3084
+ .where('status', '==', 'published')
3085
+ .get();
3086
+
3087
+ const results = [];
3088
+ for (const doc of providersSnapshot.docs) {
3089
+ const prefs = doc.data().notificationPreferences || {};
3090
+ if (prefs.dailyDigestEnabled !== false) {
3091
+ const result = await generateDailyDigest({ doctorId: doc.id });
3092
+ results.push({ doctorId: doc.id, result });
3093
+ }
3094
+ }
3095
+
3096
+ res.json({ success: true, digests: results.length });
3097
+ } catch (error) {
3098
+ console.error('Daily digest error:', error);
3099
+ res.status(500).json({ error: 'Internal server error' });
3100
+ }
3101
+ });
3102
+
3103
+ // --- Component 5: Feedback Stats ---
3104
+
3105
+ // Get feedback statistics for a doctor
3106
+ app.get('/api/doctor/feedback/stats', async (req, res) => {
3107
+ try {
3108
+ const user = await requireAuth(req, res);
3109
+ if (!user) return;
3110
+
3111
+ const providerDoc = await findProviderByOwner(user.uid);
3112
+ if (!providerDoc) {
3113
+ return res.status(404).json({ error: 'Provider not found' });
3114
+ }
3115
+
3116
+ const stats = await getFeedbackStats({ doctorId: providerDoc.id });
3117
+ res.json(stats);
3118
+ } catch (error) {
3119
+ console.error('Get feedback stats error:', error);
3120
+ res.status(500).json({ error: 'Internal server error' });
3121
+ }
3122
+ });
3123
+
3124
+ // ============================================
3125
+ // WAITLIST
3126
+ // ============================================
3127
+
3128
+ // Public: check launch status
3129
+ app.get('/api/launch-status', (req, res) => {
3130
+ const now = new Date();
3131
+ res.json({
3132
+ launchDate: LAUNCH_DATE.toISOString(),
3133
+ isOpen: now >= LAUNCH_DATE
3134
+ });
3135
+ });
3136
+
3137
+ // Public: join waitlist
3138
+ app.post('/api/waitlist', rateLimit({ maxRequests: 5, windowMs: 60000 }), async (req, res) => {
3139
+ try {
3140
+ const { ok, value } = validate({
3141
+ email: { type: 'email', required: true },
3142
+ name: { type: 'string', maxLength: 100 },
3143
+ specialty: { type: 'string', maxLength: 100 },
3144
+ country: { type: 'string', maxLength: 60 }
3145
+ }, req.body || {});
3146
+ if (!ok) {
3147
+ return res.status(400).json({ error: 'Valid email required' });
3148
+ }
3149
+
3150
+ const db = admin.firestore();
3151
+ // Deduplicate by email (validator already lowercased + trimmed it)
3152
+ const existing = await db.collection('waitlist').where('email', '==', value.email).limit(1).get();
3153
+ if (!existing.empty) {
3154
+ return res.json({ status: 'already_registered' });
3155
+ }
3156
+
3157
+ await db.collection('waitlist').add({
3158
+ email: value.email,
3159
+ name: value.name || null,
3160
+ specialty: value.specialty || null,
3161
+ country: value.country || null,
3162
+ approved: false,
3163
+ createdAt: admin.firestore.FieldValue.serverTimestamp()
3164
+ });
3165
+
3166
+ console.log(`[Waitlist] New signup: ${value.email}`);
3167
+ res.json({ status: 'registered' });
3168
+ } catch (error) {
3169
+ console.error('Waitlist signup error:', error);
3170
+ res.status(500).json({ error: 'Failed to join waitlist' });
3171
+ }
3172
+ });
3173
+
3174
+ // ============================================
3175
+ // STRIPE DIAGNOSTICS
3176
+ // ============================================
3177
+
3178
+ // Check Stripe configuration and connectivity (requires auth)
3179
+ app.get('/api/stripe/diagnostics', async (req, res) => {
3180
+ try {
3181
+ const user = await requireAuth(req, res);
3182
+ if (!user) return;
3183
+
3184
+ const checks = {
3185
+ paymentConfigured: !!process.env.STRIPE_SECRET_KEY,
3186
+ mode: process.env.STRIPE_SECRET_KEY
3187
+ ? (process.env.STRIPE_SECRET_KEY.startsWith('sk_live_') ? 'live' : 'test')
3188
+ : null,
3189
+ webhookConfigured: !!process.env.STRIPE_WEBHOOK_SECRET,
3190
+ apiReachable: false
3191
+ };
3192
+
3193
+ try {
3194
+ await stripe.accounts.list({ limit: 1 });
3195
+ checks.apiReachable = true;
3196
+ } catch (err) {
3197
+ checks.apiReachable = false;
3198
+ }
3199
+
3200
+ res.json(checks);
3201
+ } catch (error) {
3202
+ res.status(500).json({ error: 'Diagnostics check failed' });
3203
+ }
3204
+ });
3205
+
3206
+ // ============================================
3207
+ // STRIPE CONNECT API ENDPOINTS
3208
+ // ============================================
3209
+
3210
+ // Create Stripe Connect account for provider
3211
+ app.post('/api/stripe/create-connect-account', async (req, res) => {
3212
+ try {
3213
+ const user = await requireAuth(req, res);
3214
+ if (!user) return;
3215
+
3216
+ const providerDoc = await findProviderByOwner(user.uid);
3217
+ if (!providerDoc) {
3218
+ return res.status(404).json({ error: 'Provider not found' });
3219
+ }
3220
+
3221
+ const provider = providerDoc.data();
3222
+
3223
+ // Check if already has Connect account
3224
+ if (provider.stripeAccountId) {
3225
+ return res.json({
3226
+ accountId: provider.stripeAccountId,
3227
+ onboardingStatus: provider.stripeOnboardingStatus || 'PENDING'
3228
+ });
3229
+ }
3230
+
3231
+ const result = await createConnectAccount({
3232
+ providerId: providerDoc.id,
3233
+ email: user.email,
3234
+ displayName: provider.displayName,
3235
+ country: provider.country || 'US'
3236
+ });
3237
+
3238
+ res.json(result);
3239
+ } catch (error) {
3240
+ console.error('Error creating Connect account:', error);
3241
+ res.status(500).json({ error: 'Failed to create Connect account' });
3242
+ }
3243
+ });
3244
+
3245
+ // Create onboarding link for Connect account
3246
+ app.post('/api/stripe/create-account-link', async (req, res) => {
3247
+ try {
3248
+ const user = await requireAuth(req, res);
3249
+ if (!user) return;
3250
+
3251
+ const providerDoc = await findProviderByOwner(user.uid);
3252
+ if (!providerDoc) {
3253
+ return res.status(404).json({ error: 'Provider not found' });
3254
+ }
3255
+
3256
+ const provider = providerDoc.data();
3257
+
3258
+ if (!provider.stripeAccountId) {
3259
+ return res.status(400).json({ error: 'No Stripe Connect account found' });
3260
+ }
3261
+
3262
+ const result = await createAccountLink({
3263
+ stripeAccountId: provider.stripeAccountId,
3264
+ providerId: providerDoc.id
3265
+ });
3266
+
3267
+ res.json(result);
3268
+ } catch (error) {
3269
+ console.error('Error creating account link:', error);
3270
+ res.status(500).json({ error: 'Internal server error' });
3271
+ }
3272
+ });
3273
+
3274
+ // Refresh Connect account status
3275
+ app.post('/api/stripe/refresh-account-status', async (req, res) => {
3276
+ try {
3277
+ const user = await requireAuth(req, res);
3278
+ if (!user) return;
3279
+
3280
+ const providerDoc = await findProviderByOwner(user.uid);
3281
+ if (!providerDoc) {
3282
+ return res.status(404).json({ error: 'Provider not found' });
3283
+ }
3284
+
3285
+ const provider = providerDoc.data();
3286
+
3287
+ if (!provider.stripeAccountId) {
3288
+ return res.status(400).json({ error: 'No Stripe Connect account found' });
3289
+ }
3290
+
3291
+ const result = await refreshAccountStatus({
3292
+ providerId: providerDoc.id,
3293
+ stripeAccountId: provider.stripeAccountId
3294
+ });
3295
+
3296
+ res.json(result);
3297
+ } catch (error) {
3298
+ console.error('Error refreshing account status:', error);
3299
+ res.status(500).json({ error: 'Internal server error' });
3300
+ }
3301
+ });
3302
+
3303
+ // Set provider pricing (creates Stripe Products/Prices)
3304
+ app.post('/api/stripe/set-pricing', async (req, res) => {
3305
+ try {
3306
+ const user = await requireAuth(req, res);
3307
+ if (!user) return;
3308
+
3309
+ const providerDoc = await findProviderByOwner(user.uid);
3310
+ if (!providerDoc) {
3311
+ return res.status(404).json({ error: 'Provider not found' });
3312
+ }
3313
+
3314
+ const provider = providerDoc.data();
3315
+
3316
+ if (!provider.stripeAccountId) {
3317
+ return res.status(400).json({ error: 'Must complete Stripe Connect onboarding first' });
3318
+ }
3319
+
3320
+ if (!provider.chargesEnabled) {
3321
+ return res.status(400).json({ error: 'Stripe account not yet approved for charges' });
3322
+ }
3323
+
3324
+ const { currency = 'usd', prices } = req.body;
3325
+ const validCurrencies = ['usd', 'eur', 'gbp', 'cad', 'aud'];
3326
+
3327
+ if (!validCurrencies.includes(String(currency).toLowerCase())) {
3328
+ return res.status(400).json({ error: `Invalid currency. Supported: ${validCurrencies.join(', ')}` });
3329
+ }
3330
+
3331
+ if (!prices || typeof prices !== 'object' || Array.isArray(prices)) {
3332
+ return res.status(400).json({ error: 'Invalid prices format' });
3333
+ }
3334
+
3335
+ for (const [key, val] of Object.entries(prices)) {
3336
+ if (typeof val === 'number' && (val < 0 || val > 100000)) {
3337
+ return res.status(400).json({ error: `Price "${key}" must be between 0 and 100000` });
3338
+ }
3339
+ }
3340
+
3341
+ const result = await setProviderPricing({
3342
+ providerId: providerDoc.id,
3343
+ stripeAccountId: provider.stripeAccountId,
3344
+ currency,
3345
+ prices,
3346
+ displayName: provider.displayName
3347
+ });
3348
+
3349
+ res.json(result);
3350
+ } catch (error) {
3351
+ console.error('Error setting pricing:', error);
3352
+ res.status(500).json({ error: 'Internal server error' });
3353
+ }
3354
+ });
3355
+
3356
+ // ============================================
3357
+ // PAYMENT API ENDPOINTS
3358
+ // ============================================
3359
+
3360
+ // Get provider pricing configuration
3361
+ app.get('/api/payment/config/:handle', async (req, res) => {
3362
+ try {
3363
+ const handle = normalizeHandle(req.params.handle);
3364
+ const providerDoc = await findProviderByHandle(handle);
3365
+
3366
+ if (!providerDoc) {
3367
+ return res.status(404).json({ error: 'Provider not found' });
3368
+ }
3369
+
3370
+ const provider = providerDoc.data();
3371
+ if (provider.status !== 'published' && provider.status !== 'preview') {
3372
+ return res.status(404).json({ error: 'Provider not found' });
3373
+ }
3374
+
3375
+ res.json({
3376
+ pricingModel: provider.pricingModel || 'free',
3377
+ subscriptionPrice: provider.subscriptionPrice || 0,
3378
+ perChatPrice: provider.perChatPrice || 0,
3379
+ paymentEnabled: provider.paymentEnabled || false,
3380
+ currency: provider.currency || 'usd',
3381
+ stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY
3382
+ });
3383
+ } catch (error) {
3384
+ console.error('Error getting payment config:', error);
3385
+ res.status(500).json({ error: 'Internal server error' });
3386
+ }
3387
+ });
3388
+
3389
+ // Update provider pricing configuration (requires auth)
3390
+ app.post('/api/payment/update-pricing', async (req, res) => {
3391
+ try {
3392
+ const user = await requireAuth(req, res);
3393
+ if (!user) return;
3394
+
3395
+ const providerDoc = await findProviderByOwner(user.uid);
3396
+ if (!providerDoc) {
3397
+ return res.status(404).json({ error: 'Provider not found' });
3398
+ }
3399
+
3400
+ const {
3401
+ pricingModel,
3402
+ subscriptionPrice,
3403
+ perChatPrice,
3404
+ paymentEnabled
3405
+ } = req.body;
3406
+
3407
+ // Validate pricing model
3408
+ const validModels = ['free', 'subscription', 'per-chat', 'hybrid'];
3409
+ if (pricingModel && !validModels.includes(pricingModel)) {
3410
+ return res.status(400).json({ error: 'Invalid pricing model' });
3411
+ }
3412
+
3413
+ // Validate prices — coerce to a finite number first so string inputs like
3414
+ // "999999" can't slip past the numeric range check and be stored verbatim.
3415
+ let subPrice, chatPrice;
3416
+ if (subscriptionPrice !== undefined) {
3417
+ subPrice = Number(subscriptionPrice);
3418
+ if (!Number.isFinite(subPrice) || subPrice < 0 || subPrice > 10000) {
3419
+ return res.status(400).json({ error: 'Invalid subscription price' });
3420
+ }
3421
+ }
3422
+ if (perChatPrice !== undefined) {
3423
+ chatPrice = Number(perChatPrice);
3424
+ if (!Number.isFinite(chatPrice) || chatPrice < 0 || chatPrice > 1000) {
3425
+ return res.status(400).json({ error: 'Invalid per-chat price' });
3426
+ }
3427
+ }
3428
+
3429
+ const updates = {
3430
+ updatedAt: nowTimestamp()
3431
+ };
3432
+
3433
+ if (pricingModel !== undefined) updates.pricingModel = pricingModel;
3434
+ if (subscriptionPrice !== undefined) updates.subscriptionPrice = subPrice;
3435
+ if (perChatPrice !== undefined) updates.perChatPrice = chatPrice;
3436
+ if (paymentEnabled !== undefined) updates.paymentEnabled = paymentEnabled;
3437
+ if (!updates.currency) updates.currency = 'usd';
3438
+
3439
+ await providerDoc.ref.update(updates);
3440
+
3441
+ res.json({ ok: true, updates });
3442
+ } catch (error) {
3443
+ console.error('Error updating pricing:', error);
3444
+ res.status(500).json({ error: 'Internal server error' });
3445
+ }
3446
+ });
3447
+
3448
+ // Create payment intent for per-chat payment
3449
+ app.post('/api/payment/create-intent', rateLimit({ maxRequests: 15, windowMs: 60000 }), async (req, res) => {
3450
+ try {
3451
+ const { handle, sessionId, conversationId } = req.body;
3452
+
3453
+ if (!handle || !sessionId) {
3454
+ return res.status(400).json({ error: 'handle and sessionId required' });
3455
+ }
3456
+
3457
+ const providerDoc = await findProviderByHandle(handle);
3458
+ if (!providerDoc) {
3459
+ return res.status(404).json({ error: 'Provider not found' });
3460
+ }
3461
+
3462
+ const provider = providerDoc.data();
3463
+ if (!provider.paymentEnabled || !provider.perChatPrice) {
3464
+ return res.status(400).json({ error: 'Per-chat payment not enabled' });
3465
+ }
3466
+
3467
+ if (!provider.stripeAccountId) {
3468
+ return res.status(400).json({ error: 'Provider has not set up payments yet' });
3469
+ }
3470
+
3471
+ if (!provider.chargesEnabled) {
3472
+ return res.status(400).json({ error: 'Provider payment account not yet approved' });
3473
+ }
3474
+
3475
+ const result = await createPaymentIntent({
3476
+ providerId: providerDoc.id,
3477
+ patientId: sessionId,
3478
+ amount: provider.perChatPrice,
3479
+ currency: provider.currency || 'usd',
3480
+ metadata: { conversationId },
3481
+ stripeAccountId: provider.stripeAccountId
3482
+ });
3483
+
3484
+ res.json(result);
3485
+ } catch (error) {
3486
+ console.error('Error creating payment intent:', error);
3487
+ res.status(500).json({ error: 'Internal server error' });
3488
+ }
3489
+ });
3490
+
3491
+ // Create Stripe Checkout Session for subscription (Connect)
3492
+ app.post('/api/payment/create-checkout-session', rateLimit({ maxRequests: 15, windowMs: 60000 }), async (req, res) => {
3493
+ try {
3494
+ const { handle, sessionId, interval = 'month' } = req.body;
3495
+
3496
+ const VALID_INTERVALS = ['month', 'year'];
3497
+ if (!handle || !sessionId) {
3498
+ return res.status(400).json({ error: 'handle and sessionId required' });
3499
+ }
3500
+ if (!VALID_INTERVALS.includes(interval)) {
3501
+ return res.status(400).json({ error: 'interval must be month or year' });
3502
+ }
3503
+
3504
+ const providerDoc = await findProviderByHandle(handle);
3505
+ if (!providerDoc) {
3506
+ return res.status(404).json({ error: 'Provider not found' });
3507
+ }
3508
+
3509
+ const provider = providerDoc.data();
3510
+
3511
+ // Check Stripe Connect setup
3512
+ if (!provider.stripeAccountId) {
3513
+ return res.status(400).json({ error: 'Provider has not set up payments yet' });
3514
+ }
3515
+
3516
+ if (!provider.chargesEnabled) {
3517
+ return res.status(400).json({ error: 'Provider payment account not yet approved' });
3518
+ }
3519
+
3520
+ if (!provider.stripePriceIds || !provider.stripePriceIds[interval]) {
3521
+ return res.status(400).json({ error: `${interval} subscription not available` });
3522
+ }
3523
+
3524
+ const result = await createCheckoutSession({
3525
+ providerId: providerDoc.id,
3526
+ patientId: sessionId,
3527
+ interval,
3528
+ stripeAccountId: provider.stripeAccountId,
3529
+ stripePriceId: provider.stripePriceIds[interval]
3530
+ });
3531
+
3532
+ res.json(result);
3533
+ } catch (error) {
3534
+ console.error('Error creating checkout session:', error);
3535
+ res.status(500).json({ error: 'Unable to create checkout session' });
3536
+ }
3537
+ });
3538
+
3539
+ // Create subscription (legacy - for backwards compatibility)
3540
+ app.post('/api/payment/create-subscription', rateLimit({ maxRequests: 15, windowMs: 60000 }), async (req, res) => {
3541
+ try {
3542
+ const { handle, sessionId } = req.body;
3543
+
3544
+ if (!handle || !sessionId) {
3545
+ return res.status(400).json({ error: 'handle and sessionId required' });
3546
+ }
3547
+
3548
+ const providerDoc = await findProviderByHandle(handle);
3549
+ if (!providerDoc) {
3550
+ return res.status(404).json({ error: 'Provider not found' });
3551
+ }
3552
+
3553
+ const provider = providerDoc.data();
3554
+ if (!provider.paymentEnabled || !provider.subscriptionPrice) {
3555
+ return res.status(400).json({ error: 'Subscription payment not enabled' });
3556
+ }
3557
+
3558
+ const result = await createSubscription({
3559
+ providerId: providerDoc.id,
3560
+ patientId: sessionId,
3561
+ priceAmount: provider.subscriptionPrice,
3562
+ currency: provider.currency || 'usd'
3563
+ });
3564
+
3565
+ res.json(result);
3566
+ } catch (error) {
3567
+ console.error('Error creating subscription:', error);
3568
+ res.status(500).json({ error: 'Unable to create subscription' });
3569
+ }
3570
+ });
3571
+
3572
+ // Cancel subscription (requires auth)
3573
+ app.post('/api/payment/cancel-subscription', async (req, res) => {
3574
+ try {
3575
+ const user = await requireAuth(req, res);
3576
+ if (!user) return;
3577
+
3578
+ const { subscriptionId, cancelAtPeriodEnd } = req.body;
3579
+
3580
+ if (!subscriptionId) {
3581
+ return res.status(400).json({ error: 'subscriptionId required' });
3582
+ }
3583
+
3584
+ // Ownership check: the caller's provider must own this subscription,
3585
+ // otherwise a doctor could cancel any subscription by id (IDOR).
3586
+ const providerDoc = await findProviderByOwner(user.uid);
3587
+ if (!providerDoc) {
3588
+ return res.status(404).json({ error: 'Provider not found' });
3589
+ }
3590
+ const db = admin.firestore();
3591
+ const subSnap = await db.collection('subscriptions')
3592
+ .where('stripeSubscriptionId', '==', subscriptionId)
3593
+ .limit(1)
3594
+ .get();
3595
+ if (subSnap.empty || subSnap.docs[0].data().providerId !== providerDoc.id) {
3596
+ return res.status(403).json({ error: 'Forbidden' });
3597
+ }
3598
+
3599
+ const result = await cancelSubscription({
3600
+ subscriptionId,
3601
+ cancelAtPeriodEnd: cancelAtPeriodEnd !== false
3602
+ });
3603
+
3604
+ res.json({ ok: true, subscription: result });
3605
+ } catch (error) {
3606
+ console.error('Error canceling subscription:', error);
3607
+ res.status(500).json({ error: 'Internal server error' });
3608
+ }
3609
+ });
3610
+
3611
+ // Check payment access
3612
+ app.post('/api/payment/check-access', rateLimit({ maxRequests: 30, windowMs: 60000 }), async (req, res) => {
3613
+ try {
3614
+ const { handle, sessionId, conversationId } = req.body;
3615
+
3616
+ if (!handle || !sessionId) {
3617
+ return res.status(400).json({ error: 'handle and sessionId required' });
3618
+ }
3619
+
3620
+ const providerDoc = await findProviderByHandle(handle);
3621
+ if (!providerDoc) {
3622
+ return res.status(404).json({ error: 'Provider not found' });
3623
+ }
3624
+
3625
+ const provider = providerDoc.data();
3626
+
3627
+ // If payment not enabled, allow access
3628
+ if (!provider.paymentEnabled || provider.pricingModel === 'free') {
3629
+ return res.json({ hasAccess: true, type: 'free' });
3630
+ }
3631
+
3632
+ const result = await checkPaymentAccess({
3633
+ providerId: providerDoc.id,
3634
+ patientId: sessionId,
3635
+ conversationId
3636
+ });
3637
+
3638
+ res.json(result);
3639
+ } catch (error) {
3640
+ console.error('Error checking payment access:', error);
3641
+ res.status(500).json({ error: 'Internal server error' });
3642
+ }
3643
+ });
3644
+
3645
+ // Get revenue analytics (requires auth)
3646
+ app.get('/api/payment/revenue', async (req, res) => {
3647
+ try {
3648
+ const user = await requireAuth(req, res);
3649
+ if (!user) return;
3650
+
3651
+ const providerDoc = await findProviderByOwner(user.uid);
3652
+ if (!providerDoc) {
3653
+ return res.status(404).json({ error: 'Provider not found' });
3654
+ }
3655
+
3656
+ const { startDate, endDate } = req.query;
3657
+
3658
+ const result = await getRevenueAnalytics({
3659
+ providerId: providerDoc.id,
3660
+ startDate: startDate ? admin.firestore.Timestamp.fromDate(new Date(startDate)) : null,
3661
+ endDate: endDate ? admin.firestore.Timestamp.fromDate(new Date(endDate)) : null
3662
+ });
3663
+
3664
+ res.json(result);
3665
+ } catch (error) {
3666
+ console.error('Error getting revenue analytics:', error);
3667
+ res.status(500).json({ error: 'Internal server error' });
3668
+ }
3669
+ });
3670
+
3671
+ // Get transaction history (requires auth)
3672
+ app.get('/api/payment/transactions', async (req, res) => {
3673
+ try {
3674
+ const user = await requireAuth(req, res);
3675
+ if (!user) return;
3676
+
3677
+ const providerDoc = await findProviderByOwner(user.uid);
3678
+ if (!providerDoc) {
3679
+ return res.status(404).json({ error: 'Provider not found' });
3680
+ }
3681
+
3682
+ const db = admin.firestore();
3683
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
3684
+
3685
+ const snapshot = await db
3686
+ .collection('transactions')
3687
+ .where('providerId', '==', providerDoc.id)
3688
+ .orderBy('createdAt', 'desc')
3689
+ .limit(limit)
3690
+ .get();
3691
+
3692
+ const transactions = snapshot.docs.map(doc => ({
3693
+ id: doc.id,
3694
+ ...doc.data(),
3695
+ createdAt: doc.data().createdAt?.toDate().toISOString()
3696
+ }));
3697
+
3698
+ res.json({ transactions });
3699
+ } catch (error) {
3700
+ console.error('Error getting transactions:', error);
3701
+ res.status(500).json({ error: 'Internal server error' });
3702
+ }
3703
+ });
3704
+
3705
+ // Delete conversation (provider only)
3706
+ app.delete('/api/conversations/:threadId', async (req, res) => {
3707
+ try {
3708
+ const user = await requireAuth(req, res);
3709
+ if (!user) return;
3710
+
3711
+ const providerDoc = await findProviderByOwner(user.uid);
3712
+ if (!providerDoc) {
3713
+ return res.status(404).json({ error: 'Provider not found' });
3714
+ }
3715
+
3716
+ const { threadId } = req.params;
3717
+ if (!threadId) {
3718
+ return res.status(400).json({ error: 'threadId is required' });
3719
+ }
3720
+
3721
+ const db = admin.firestore();
3722
+ const threadRef = db.collection('threads').doc(threadId);
3723
+ const threadDoc = await threadRef.get();
3724
+
3725
+ if (!threadDoc.exists) {
3726
+ return res.status(404).json({ error: 'Conversation not found' });
3727
+ }
3728
+
3729
+ if (threadDoc.data().doctorId !== providerDoc.id) {
3730
+ return res.status(403).json({ error: 'Not authorized to delete this conversation' });
3731
+ }
3732
+
3733
+ // Delete all messages in the thread
3734
+ const messagesSnapshot = await threadRef.collection('messages').get();
3735
+ const batch = db.batch();
3736
+ messagesSnapshot.docs.forEach(doc => batch.delete(doc.ref));
3737
+ batch.delete(threadRef);
3738
+ await batch.commit();
3739
+
3740
+ logAuditEvent({
3741
+ action: 'conversation_deleted',
3742
+ actorId: user.uid,
3743
+ actorType: 'provider',
3744
+ resourceType: 'thread',
3745
+ resourceId: threadId,
3746
+ meta: { providerId: providerDoc.id }
3747
+ }).catch(() => {});
3748
+
3749
+ res.json({ success: true });
3750
+ } catch (error) {
3751
+ console.error('Error deleting conversation:', error);
3752
+ res.status(500).json({ error: 'Internal server error' });
3753
+ }
3754
+ });
3755
+
3756
+ // Stripe webhook endpoint
3757
+ app.post('/api/payment/webhook', async (req, res) => {
3758
+ const sig = req.headers['stripe-signature'];
3759
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
3760
+
3761
+ if (!webhookSecret) {
3762
+ console.error('Webhook secret not configured');
3763
+ return res.status(500).send('Webhook secret not configured');
3764
+ }
3765
+
3766
+ let event;
3767
+
3768
+ try {
3769
+ const payload = req.rawBody || Buffer.from(JSON.stringify(req.body || {}));
3770
+ event = stripe.webhooks.constructEvent(payload, sig, webhookSecret);
3771
+ } catch (err) {
3772
+ console.error('Webhook signature verification failed:', err.message);
3773
+ return res.status(400).send(`Webhook Error: ${err.message}`);
3774
+ }
3775
+
3776
+ try {
3777
+ await handleStripeWebhook(event);
3778
+ } catch (error) {
3779
+ // Log but don't return 500 — returning 500 causes Stripe to retry
3780
+ // the webhook for up to 72 hours, creating a retry storm
3781
+ console.error('Error handling webhook:', error);
3782
+ }
3783
+ res.json({ received: true });
3784
+ });
3785
+
3786
+ // ============================================
3787
+ // FRONTEND ROUTES
3788
+ // ============================================
3789
+
3790
+ // Serve provider console at /app
3791
+ app.get('/app/inbox', (req, res) => {
3792
+ res.sendFile(path.join(__dirname, 'public/app/inbox.html'));
3793
+ });
3794
+
3795
+ app.get('/app*', (req, res) => {
3796
+ res.sendFile(path.join(__dirname, 'public/app/index.html'));
3797
+ });
3798
+
3799
+ // Serve patient chat page for handle-based URLs
3800
+ app.get('/:handle', async (req, res) => {
3801
+ const handle = req.params.handle;
3802
+ // Don't serve chat page for special paths
3803
+ if (handle === 'api' || handle === 'health' || handle === 'favicon.ico' || handle === 'find-doctor.html') {
3804
+ return res.status(404).json({ error: 'Not found' });
3805
+ }
3806
+ // Pre-launch: redirect doctor profile clicks to the waitlist
3807
+ if (new Date() < LAUNCH_DATE) {
3808
+ return res.redirect('/#waitlist');
3809
+ }
3810
+ // Validate handle exists before serving chat page
3811
+ try {
3812
+ const providerDoc = await findProviderByHandle(handle);
3813
+ if (!providerDoc) {
3814
+ return res.status(404).sendFile(path.join(__dirname, 'public/404.html'));
3815
+ }
3816
+ } catch (err) {
3817
+ // On error, still serve chat.html and let client handle it
3818
+ }
3819
+ res.sendFile(path.join(__dirname, 'public/chat.html'));
3820
+ });
3821
+
3822
+ // Fallback for root
3823
+ app.get('/', (req, res) => {
3824
+ res.sendFile(path.join(__dirname, 'public/index.html'));
3825
+ });
3826
+
3827
+ // 404 handler
3828
+ app.use((req, res) => {
3829
+ if (req.accepts('html')) {
3830
+ return res.status(404).sendFile(path.join(__dirname, 'public/404.html'));
3831
+ }
3832
+ res.status(404).json({ error: 'Not found' });
3833
+ });
3834
+
3835
+ // Error handler
3836
+ app.use((err, req, res, next) => {
3837
+ const rid = req && req.requestId ? req.requestId : 'no-rid';
3838
+ console.error(`Unhandled error [${rid}]:`, err);
3839
+ // Never leak internals; return the correlation id so support can trace it.
3840
+ res.status(500).json({ error: 'Internal server error', requestId: rid });
3841
+ });
3842
+
3843
+ // Start server (skip in test environment — supertest handles it)
3844
+ if (process.env.NODE_ENV !== 'test') {
3845
+ // Stability net: log async faults instead of dying silently. A managed
3846
+ // platform can still restart on a true crash, but a single bad async path
3847
+ // should not take the whole service down without a trace.
3848
+ process.on('unhandledRejection', (reason) => {
3849
+ console.error('[unhandledRejection]', reason && reason.stack ? reason.stack : reason);
3850
+ });
3851
+ process.on('uncaughtException', (err) => {
3852
+ console.error('[uncaughtException]', err && err.stack ? err.stack : err);
3853
+ });
3854
+
3855
+ app.listen(PORT, () => {
3856
+ console.log(`\nšŸ„ EvalDoc Server Running\n`);
3857
+ console.log(` Local: http://localhost:${PORT}`);
3858
+ console.log(` Provider Console: http://localhost:${PORT}/app`);
3859
+ console.log(` Health Check: http://localhost:${PORT}/health\n`);
3860
+ console.log(`Press Ctrl+C to stop\n`);
3861
+ });
3862
+ }
3863
+
3864
+ module.exports = app;