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.
- package/.env.staging.example +19 -0
- package/.firebaserc +6 -0
- package/.github/workflows/auto-merge-to-main.yml +28 -0
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/npxhub-publish.yml +170 -0
- package/.prettierignore +4 -0
- package/.prettierrc.json +7 -0
- package/README.md +142 -0
- package/SRS.md +448 -0
- package/apphosting.staging.yaml +48 -0
- package/apphosting.yaml +50 -0
- package/bin/evaldoc.cjs +105 -0
- package/cors.json +8 -0
- package/docs/ARCHITECTURE.md +769 -0
- package/docs/ARCHITECTURE_SUMMARY.md +323 -0
- package/docs/DEPLOYMENT.md +86 -0
- package/docs/DEPLOYMENT_STEPS.md +373 -0
- package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
- package/docs/DEPLOY_STORAGE_RULES.md +82 -0
- package/docs/DOCTOR_TUTORIAL.md +467 -0
- package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
- package/docs/FIXES_APPLIED.md +128 -0
- package/docs/IMPLEMENTATION.md +498 -0
- package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
- package/docs/LLM-SETUP.md +216 -0
- package/docs/MANUAL_TEST_GUIDE.md +327 -0
- package/docs/PATIENT_GUIDE.md +451 -0
- package/docs/PLATFORM_OVERVIEW.md +194 -0
- package/docs/QUICKSTART.md +252 -0
- package/docs/QUICK_REFERENCE.md +222 -0
- package/docs/READY_TO_TEST.md +383 -0
- package/docs/RUN.md +237 -0
- package/docs/START_TESTING.md +130 -0
- package/docs/STRIPE_CONNECT_SETUP.md +454 -0
- package/docs/SYSTEM_READY.md +345 -0
- package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
- package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
- package/docs/firebasefiles.md +21 -0
- package/docs/homepage.md +1208 -0
- package/docs/payments.md +159 -0
- package/eslint.config.js +76 -0
- package/firebase.json +27 -0
- package/firestore.indexes.json +88 -0
- package/firestore.rules +125 -0
- package/functions/package-lock.json +2884 -0
- package/functions/package.json +20 -0
- package/functions/src/analytics.js +101 -0
- package/functions/src/audit.js +28 -0
- package/functions/src/auth.js +19 -0
- package/functions/src/drafts.js +128 -0
- package/functions/src/feedback.js +164 -0
- package/functions/src/firebase-init.js +29 -0
- package/functions/src/i18n.js +134 -0
- package/functions/src/limits.js +27 -0
- package/functions/src/llm.js +271 -0
- package/functions/src/ner.js +162 -0
- package/functions/src/notifications.js +273 -0
- package/functions/src/orgs.js +127 -0
- package/functions/src/patient-api.js +545 -0
- package/functions/src/payment.js +830 -0
- package/functions/src/safety.js +188 -0
- package/functions/src/stripe-config.js +33 -0
- package/functions/src/topics.js +94 -0
- package/functions/src/triage.js +191 -0
- package/functions/src/utils.js +28 -0
- package/functions/src/validate.js +177 -0
- package/functions/test-firebase.js +36 -0
- package/jest.config.js +18 -0
- package/package.json +52 -0
- package/public/404.html +93 -0
- package/public/app/analytics.html +336 -0
- package/public/app/analytics.js +177 -0
- package/public/app/app.css +1400 -0
- package/public/app/app.js +1754 -0
- package/public/app/config.js +14 -0
- package/public/app/inbox-threads.js +843 -0
- package/public/app/inbox.html +598 -0
- package/public/app/inbox.js +397 -0
- package/public/app/index.html +612 -0
- package/public/assets/logo.jpg +0 -0
- package/public/chat.html +137 -0
- package/public/css/chat.css +870 -0
- package/public/css/landing-enhance.css +298 -0
- package/public/css/styles.css +1198 -0
- package/public/favicon.svg +5 -0
- package/public/find-doctor.html +220 -0
- package/public/index.html +399 -0
- package/public/js/chat-threads.js +513 -0
- package/public/js/chat.js +832 -0
- package/public/js/search.js +41 -0
- package/public/privacy.html +101 -0
- package/public/robots.txt +6 -0
- package/public/site.webmanifest +12 -0
- package/public/terms.html +110 -0
- package/scripts/create-test-provider.js +55 -0
- package/scripts/deploy-staging.sh +51 -0
- package/scripts/fix-provider.js +39 -0
- package/scripts/quick-test.sh +37 -0
- package/scripts/test-chat.sh +54 -0
- package/scripts/test-multi-turn.js +137 -0
- package/scripts/test-ner-simple.js +28 -0
- package/scripts/test-ner.js +60 -0
- package/scripts/test-stripe-connect.js +304 -0
- package/scripts/test-webhook-secret.sh +24 -0
- package/server.js +3864 -0
- package/storage.rules +22 -0
- package/test-api.json +1 -0
- package/tests/emulator/README.md +38 -0
- package/tests/emulator/counters.emulator.test.js +81 -0
- package/tests/integration/api-account-selfserve.test.js +312 -0
- package/tests/integration/api-admin-digest.test.js +202 -0
- package/tests/integration/api-chat-satisfaction.test.js +259 -0
- package/tests/integration/api-doctor-drafts.test.js +236 -0
- package/tests/integration/api-doctor-inbox.test.js +401 -0
- package/tests/integration/api-org.test.js +328 -0
- package/tests/integration/api-payment.test.js +664 -0
- package/tests/integration/api-provider-settings.test.js +475 -0
- package/tests/integration/api-qr.test.js +121 -0
- package/tests/integration/api-security.test.js +241 -0
- package/tests/integration/api-stripe-search.test.js +303 -0
- package/tests/integration/api-webhook-deletion.test.js +231 -0
- package/tests/mocks/firebase-admin.js +220 -0
- package/tests/unit/analytics.test.js +151 -0
- package/tests/unit/audit-bugs.test.js +203 -0
- package/tests/unit/chat-ui-logic.test.js +283 -0
- package/tests/unit/drafts.test.js +104 -0
- package/tests/unit/feedback.test.js +148 -0
- package/tests/unit/i18n.test.js +229 -0
- package/tests/unit/llm-retry.test.js +122 -0
- package/tests/unit/notifications.test.js +155 -0
- package/tests/unit/orgs.test.js +328 -0
- package/tests/unit/payment.test.js +889 -0
- package/tests/unit/safety-edge-cases.test.js +509 -0
- package/tests/unit/safety-srs-compliance.test.js +252 -0
- package/tests/unit/safety.test.js +291 -0
- package/tests/unit/server-helpers.test.js +774 -0
- package/tests/unit/topics-limits.test.js +158 -0
- package/tests/unit/triage.test.js +152 -0
- 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;
|