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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organizations & RBAC — pure logic core for multi-provider clinics.
|
|
3
|
+
*
|
|
4
|
+
* This module is intentionally side-effect-free so authorization decisions and
|
|
5
|
+
* seat math are exhaustively unit-testable. Firestore reads/writes live in the
|
|
6
|
+
* server endpoints; this file only answers "is this allowed?" and "how many
|
|
7
|
+
* seats?" questions.
|
|
8
|
+
*
|
|
9
|
+
* Roles (highest → lowest authority):
|
|
10
|
+
* owner — billing, delete org, manage everyone, full access
|
|
11
|
+
* admin — manage members (not owner), org settings, view all inboxes
|
|
12
|
+
* clinician — own provider profile + own inbox, reply to patients
|
|
13
|
+
* staff — triage/reply in assigned inboxes only (no clinical settings)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ROLES = ['owner', 'admin', 'clinician', 'staff'];
|
|
17
|
+
|
|
18
|
+
// Higher number = more authority. Used for "can actor manage target?" checks.
|
|
19
|
+
const ROLE_RANK = {
|
|
20
|
+
owner: 4,
|
|
21
|
+
admin: 3,
|
|
22
|
+
clinician: 2,
|
|
23
|
+
staff: 1
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// action -> roles allowed to perform it
|
|
27
|
+
const PERMISSIONS = {
|
|
28
|
+
'billing.manage': ['owner'],
|
|
29
|
+
'org.delete': ['owner'],
|
|
30
|
+
'members.invite': ['owner', 'admin'],
|
|
31
|
+
'members.remove': ['owner', 'admin'],
|
|
32
|
+
'members.role': ['owner', 'admin'],
|
|
33
|
+
'inbox.viewAll': ['owner', 'admin'],
|
|
34
|
+
'settings.edit': ['owner', 'admin'],
|
|
35
|
+
'provider.own': ['owner', 'admin', 'clinician'],
|
|
36
|
+
'inbox.viewOwn': ['owner', 'admin', 'clinician', 'staff'],
|
|
37
|
+
'inbox.reply': ['owner', 'admin', 'clinician', 'staff']
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Seat caps per plan. null = unlimited.
|
|
41
|
+
const SEAT_LIMITS = {
|
|
42
|
+
free: 1,
|
|
43
|
+
pro: 1,
|
|
44
|
+
clinic: 5,
|
|
45
|
+
enterprise: null
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Is `role` allowed to perform `action`? */
|
|
49
|
+
function can(role, action) {
|
|
50
|
+
const allowed = PERMISSIONS[action];
|
|
51
|
+
if (!allowed) return false;
|
|
52
|
+
return allowed.includes(role);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Is `role` a recognized role? */
|
|
56
|
+
function isValidRole(role) {
|
|
57
|
+
return ROLES.includes(role);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Can an actor with `actorRole` manage (change role / remove) a member with
|
|
62
|
+
* `targetRole`? Only owner/admin can manage at all; an actor can never manage
|
|
63
|
+
* someone of equal-or-higher authority (so admins can't touch owners or each
|
|
64
|
+
* other, and owners can manage anyone below them).
|
|
65
|
+
*/
|
|
66
|
+
function canManageMember(actorRole, targetRole) {
|
|
67
|
+
if (!isValidRole(actorRole) || !isValidRole(targetRole)) return false;
|
|
68
|
+
if (actorRole !== 'owner' && actorRole !== 'admin') return false;
|
|
69
|
+
return ROLE_RANK[actorRole] > ROLE_RANK[targetRole];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Highest role an actor is allowed to assign when inviting/promoting. */
|
|
73
|
+
function canAssignRole(actorRole, newRole) {
|
|
74
|
+
if (!isValidRole(actorRole) || !isValidRole(newRole)) return false;
|
|
75
|
+
if (actorRole !== 'owner' && actorRole !== 'admin') return false;
|
|
76
|
+
// Nobody can mint another owner via invite/role-change; ownership transfers
|
|
77
|
+
// are a separate, explicit flow. Admins can't create admins.
|
|
78
|
+
if (newRole === 'owner') return false;
|
|
79
|
+
if (actorRole === 'admin' && newRole === 'admin') return false;
|
|
80
|
+
return ROLE_RANK[actorRole] >= ROLE_RANK[newRole];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Seat cap for a plan (null = unlimited). */
|
|
84
|
+
function seatLimitForPlan(plan) {
|
|
85
|
+
return Object.prototype.hasOwnProperty.call(SEAT_LIMITS, plan) ? SEAT_LIMITS[plan] : 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Remaining seats given a plan and current active member count. */
|
|
89
|
+
function seatsRemaining(plan, activeMemberCount) {
|
|
90
|
+
const limit = seatLimitForPlan(plan);
|
|
91
|
+
if (limit === null) return Infinity;
|
|
92
|
+
return Math.max(0, limit - activeMemberCount);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Is there room for one more member? */
|
|
96
|
+
function hasSeatAvailable(plan, activeMemberCount) {
|
|
97
|
+
return seatsRemaining(plan, activeMemberCount) > 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Default org shape for a brand-new personal org (one owner, free plan). */
|
|
101
|
+
function personalOrgDefaults({ orgId, ownerUid, name }) {
|
|
102
|
+
return {
|
|
103
|
+
id: orgId,
|
|
104
|
+
ownerUid,
|
|
105
|
+
name: name || 'My Practice',
|
|
106
|
+
type: 'personal',
|
|
107
|
+
plan: 'free',
|
|
108
|
+
seats: 1,
|
|
109
|
+
createdAt: null, // stamped by caller with serverTimestamp
|
|
110
|
+
updatedAt: null
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
ROLES,
|
|
116
|
+
ROLE_RANK,
|
|
117
|
+
PERMISSIONS,
|
|
118
|
+
SEAT_LIMITS,
|
|
119
|
+
can,
|
|
120
|
+
isValidRole,
|
|
121
|
+
canManageMember,
|
|
122
|
+
canAssignRole,
|
|
123
|
+
seatLimitForPlan,
|
|
124
|
+
seatsRemaining,
|
|
125
|
+
hasSeatAvailable,
|
|
126
|
+
personalOrgDefaults
|
|
127
|
+
};
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
const admin = require("firebase-admin");
|
|
2
|
+
const { jsonResponse, readBody, nowTimestamp } = require("./utils");
|
|
3
|
+
const { generateResponse } = require("./llm");
|
|
4
|
+
const { analyzeInput, analyzeOutput, escalationMessage } = require("./safety");
|
|
5
|
+
const { TOPICS, TOPIC_KEYS } = require("./topics");
|
|
6
|
+
|
|
7
|
+
// Phase 2 imports
|
|
8
|
+
const { classifyConversation } = require("./triage");
|
|
9
|
+
const { generateDoctorDraft } = require("./drafts");
|
|
10
|
+
const { notifyEscalation, notifyDraftReady } = require("./notifications");
|
|
11
|
+
const { retrieveRelevantExamples } = require("./feedback");
|
|
12
|
+
const { checkPaymentAccess } = require("./payment");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get or create patient profile
|
|
16
|
+
*/
|
|
17
|
+
async function getPatient(uid) {
|
|
18
|
+
const db = admin.firestore();
|
|
19
|
+
const patientDoc = await db.collection("patients").doc(uid).get();
|
|
20
|
+
|
|
21
|
+
if (!patientDoc.exists) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { id: patientDoc.id, ...patientDoc.data() };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create patient profile
|
|
30
|
+
*/
|
|
31
|
+
async function createPatient(uid, data) {
|
|
32
|
+
const db = admin.firestore();
|
|
33
|
+
const patientRef = db.collection("patients").doc(uid);
|
|
34
|
+
|
|
35
|
+
const patientData = {
|
|
36
|
+
email: data.email,
|
|
37
|
+
displayName: data.displayName || null,
|
|
38
|
+
photoURL: data.photoURL || null,
|
|
39
|
+
doctorIds: [],
|
|
40
|
+
notificationsEnabled: true,
|
|
41
|
+
fcmToken: null,
|
|
42
|
+
createdAt: nowTimestamp(admin),
|
|
43
|
+
lastActiveAt: nowTimestamp(admin)
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
await patientRef.set(patientData);
|
|
47
|
+
|
|
48
|
+
return { id: uid, ...patientData };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Find doctor by handle
|
|
53
|
+
*/
|
|
54
|
+
async function findDoctorByHandle(handle) {
|
|
55
|
+
const db = admin.firestore();
|
|
56
|
+
const snapshot = await db
|
|
57
|
+
.collection("providers")
|
|
58
|
+
.where("handle", "==", handle)
|
|
59
|
+
.where("status", "==", "published")
|
|
60
|
+
.limit(1)
|
|
61
|
+
.get();
|
|
62
|
+
|
|
63
|
+
if (snapshot.empty) return null;
|
|
64
|
+
return { id: snapshot.docs[0].id, ...snapshot.docs[0].data() };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get thread ID from patient and doctor IDs
|
|
69
|
+
*/
|
|
70
|
+
function getThreadId(patientId, doctorId) {
|
|
71
|
+
return `thread_${patientId}_${doctorId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get or create thread between patient and doctor
|
|
76
|
+
*/
|
|
77
|
+
async function getOrCreateThread(patientId, doctorId, patientData, doctorData) {
|
|
78
|
+
const db = admin.firestore();
|
|
79
|
+
const threadId = getThreadId(patientId, doctorId);
|
|
80
|
+
const threadRef = db.collection("threads").doc(threadId);
|
|
81
|
+
const threadDoc = await threadRef.get();
|
|
82
|
+
|
|
83
|
+
if (threadDoc.exists) {
|
|
84
|
+
return { id: threadDoc.id, ...threadDoc.data() };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Create new thread with Phase 2 fields
|
|
88
|
+
const threadData = {
|
|
89
|
+
patientId,
|
|
90
|
+
patientEmail: patientData.email,
|
|
91
|
+
patientName: patientData.displayName || null,
|
|
92
|
+
patientPhoto: patientData.photoURL || null,
|
|
93
|
+
patientSource: "authenticated",
|
|
94
|
+
|
|
95
|
+
doctorId,
|
|
96
|
+
doctorName: doctorData.displayName,
|
|
97
|
+
doctorHandle: doctorData.handle,
|
|
98
|
+
|
|
99
|
+
status: "active",
|
|
100
|
+
isEscalated: false,
|
|
101
|
+
doctorOverride: false,
|
|
102
|
+
|
|
103
|
+
lastMessageAt: nowTimestamp(admin),
|
|
104
|
+
lastMessageText: null,
|
|
105
|
+
lastMessageRole: null,
|
|
106
|
+
|
|
107
|
+
unreadByPatient: 0,
|
|
108
|
+
unreadByDoctor: 0,
|
|
109
|
+
messageCount: 0,
|
|
110
|
+
|
|
111
|
+
currentTopic: null,
|
|
112
|
+
|
|
113
|
+
isPaid: false,
|
|
114
|
+
paymentId: null,
|
|
115
|
+
|
|
116
|
+
// Phase 2 fields
|
|
117
|
+
triageCategory: null,
|
|
118
|
+
triageConfidence: null,
|
|
119
|
+
triageReason: null,
|
|
120
|
+
triageUpdatedAt: null,
|
|
121
|
+
|
|
122
|
+
aiDraftResponse: null,
|
|
123
|
+
aiDraftGeneratedAt: null,
|
|
124
|
+
aiDraftStatus: null,
|
|
125
|
+
|
|
126
|
+
lastDoctorNotifiedAt: null,
|
|
127
|
+
doctorResponseFedBack: false,
|
|
128
|
+
feedbackProcessedAt: null,
|
|
129
|
+
|
|
130
|
+
createdAt: nowTimestamp(admin),
|
|
131
|
+
updatedAt: nowTimestamp(admin)
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
await threadRef.set(threadData);
|
|
135
|
+
|
|
136
|
+
// Add doctor to patient's doctorIds
|
|
137
|
+
await db.collection("patients").doc(patientId).update({
|
|
138
|
+
doctorIds: admin.firestore.FieldValue.arrayUnion(doctorId),
|
|
139
|
+
lastActiveAt: nowTimestamp(admin)
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return { id: threadId, ...threadData };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Add message to thread
|
|
147
|
+
*/
|
|
148
|
+
async function addMessageToThread(threadId, role, message, metadata = {}) {
|
|
149
|
+
const db = admin.firestore();
|
|
150
|
+
const threadRef = db.collection("threads").doc(threadId);
|
|
151
|
+
const messagesRef = threadRef.collection("messages");
|
|
152
|
+
|
|
153
|
+
const messageData = {
|
|
154
|
+
threadId,
|
|
155
|
+
role, // patient, doctor, ai
|
|
156
|
+
message,
|
|
157
|
+
content: message, // dual-field for compatibility
|
|
158
|
+
|
|
159
|
+
isDoctorManual: metadata.isDoctorManual || false,
|
|
160
|
+
doctorUserId: metadata.doctorUserId || null,
|
|
161
|
+
|
|
162
|
+
readByPatient: role === "patient",
|
|
163
|
+
readByDoctor: false,
|
|
164
|
+
|
|
165
|
+
wasFiltered: metadata.wasFiltered || false,
|
|
166
|
+
isAiGenerated: metadata.isAiGenerated || false,
|
|
167
|
+
|
|
168
|
+
createdAt: nowTimestamp(admin)
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const messageRef = await messagesRef.add(messageData);
|
|
172
|
+
|
|
173
|
+
// Update thread metadata
|
|
174
|
+
const updateData = {
|
|
175
|
+
lastMessageAt: nowTimestamp(admin),
|
|
176
|
+
lastMessageText: message.substring(0, 100),
|
|
177
|
+
lastMessageRole: role,
|
|
178
|
+
messageCount: admin.firestore.FieldValue.increment(1),
|
|
179
|
+
updatedAt: nowTimestamp(admin)
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (role === "patient") {
|
|
183
|
+
updateData.unreadByDoctor = admin.firestore.FieldValue.increment(1);
|
|
184
|
+
} else {
|
|
185
|
+
updateData.unreadByPatient = admin.firestore.FieldValue.increment(1);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await threadRef.update(updateData);
|
|
189
|
+
|
|
190
|
+
return { id: messageRef.id, ...messageData };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get messages from thread
|
|
195
|
+
*/
|
|
196
|
+
async function getThreadMessages(threadId, limit = 50) {
|
|
197
|
+
const db = admin.firestore();
|
|
198
|
+
const messagesSnapshot = await db
|
|
199
|
+
.collection("threads")
|
|
200
|
+
.doc(threadId)
|
|
201
|
+
.collection("messages")
|
|
202
|
+
.orderBy("createdAt", "asc")
|
|
203
|
+
.limit(limit)
|
|
204
|
+
.get();
|
|
205
|
+
|
|
206
|
+
return messagesSnapshot.docs.map(doc => ({
|
|
207
|
+
id: doc.id,
|
|
208
|
+
...doc.data()
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Mark messages as read
|
|
214
|
+
*/
|
|
215
|
+
async function markThreadAsRead(threadId, role) {
|
|
216
|
+
const db = admin.firestore();
|
|
217
|
+
const threadRef = db.collection("threads").doc(threadId);
|
|
218
|
+
|
|
219
|
+
if (role === "patient") {
|
|
220
|
+
await threadRef.update({
|
|
221
|
+
unreadByPatient: 0
|
|
222
|
+
});
|
|
223
|
+
} else if (role === "doctor") {
|
|
224
|
+
await threadRef.update({
|
|
225
|
+
unreadByDoctor: 0
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Handle patient API routes
|
|
232
|
+
*/
|
|
233
|
+
async function handlePatientAPI(req, res, user) {
|
|
234
|
+
const db = admin.firestore();
|
|
235
|
+
|
|
236
|
+
// GET /api/patient/me - Get patient profile
|
|
237
|
+
if (req.method === "GET" && (req.path === "/patient/me" || req.path === "/me")) {
|
|
238
|
+
const patient = await getPatient(user.uid);
|
|
239
|
+
if (!patient) {
|
|
240
|
+
jsonResponse(res, 404, { error: "Patient not found" });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
jsonResponse(res, 200, patient);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// POST /api/patient/create - Create patient profile
|
|
248
|
+
if (req.method === "POST" && req.path === "/patient/create") {
|
|
249
|
+
const body = readBody(req) || {};
|
|
250
|
+
const existing = await getPatient(user.uid);
|
|
251
|
+
|
|
252
|
+
if (existing) {
|
|
253
|
+
jsonResponse(res, 200, existing);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const patient = await createPatient(user.uid, {
|
|
258
|
+
email: user.email || body.email,
|
|
259
|
+
displayName: body.displayName || user.name,
|
|
260
|
+
photoURL: body.photoURL
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
jsonResponse(res, 200, patient);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// POST /api/patient/thread - Get or create thread with doctor
|
|
268
|
+
if (req.method === "POST" && req.path === "/patient/thread") {
|
|
269
|
+
const body = readBody(req) || {};
|
|
270
|
+
const doctorHandle = body.doctorHandle;
|
|
271
|
+
|
|
272
|
+
if (!doctorHandle) {
|
|
273
|
+
jsonResponse(res, 400, { error: "doctorHandle is required" });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const patient = await getPatient(user.uid);
|
|
278
|
+
if (!patient) {
|
|
279
|
+
jsonResponse(res, 404, { error: "Patient profile not found" });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const doctor = await findDoctorByHandle(doctorHandle);
|
|
284
|
+
if (!doctor) {
|
|
285
|
+
jsonResponse(res, 404, { error: "Doctor not found" });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const thread = await getOrCreateThread(user.uid, doctor.id, patient, doctor);
|
|
290
|
+
|
|
291
|
+
jsonResponse(res, 200, { thread });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// GET /api/patient/threads - Get all patient threads
|
|
296
|
+
if (req.method === "GET" && req.path === "/patient/threads") {
|
|
297
|
+
const threadsSnapshot = await db
|
|
298
|
+
.collection("threads")
|
|
299
|
+
.where("patientId", "==", user.uid)
|
|
300
|
+
.orderBy("lastMessageAt", "desc")
|
|
301
|
+
.limit(50)
|
|
302
|
+
.get();
|
|
303
|
+
|
|
304
|
+
const threads = threadsSnapshot.docs.map(doc => ({
|
|
305
|
+
id: doc.id,
|
|
306
|
+
...doc.data()
|
|
307
|
+
}));
|
|
308
|
+
|
|
309
|
+
jsonResponse(res, 200, { threads });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// GET /api/patient/thread/:threadId/messages - Get messages
|
|
314
|
+
if (req.method === "GET" && req.path.match(/^\/patient\/thread\/[^\/]+\/messages$/)) {
|
|
315
|
+
const threadId = req.path.split("/")[3];
|
|
316
|
+
|
|
317
|
+
// Verify patient owns this thread
|
|
318
|
+
const threadDoc = await db.collection("threads").doc(threadId).get();
|
|
319
|
+
if (!threadDoc.exists || threadDoc.data().patientId !== user.uid) {
|
|
320
|
+
jsonResponse(res, 404, { error: "Thread not found" });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const messages = await getThreadMessages(threadId);
|
|
325
|
+
await markThreadAsRead(threadId, "patient");
|
|
326
|
+
|
|
327
|
+
jsonResponse(res, 200, { messages });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// POST /api/patient/message - Send message to doctor
|
|
332
|
+
if (req.method === "POST" && req.path === "/patient/message") {
|
|
333
|
+
const body = readBody(req) || {};
|
|
334
|
+
const { threadId, message, topic } = body;
|
|
335
|
+
|
|
336
|
+
if (!threadId || !message) {
|
|
337
|
+
jsonResponse(res, 400, { error: "threadId and message are required" });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Verify patient owns this thread
|
|
342
|
+
const threadDoc = await db.collection("threads").doc(threadId).get();
|
|
343
|
+
if (!threadDoc.exists || threadDoc.data().patientId !== user.uid) {
|
|
344
|
+
jsonResponse(res, 404, { error: "Thread not found" });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const thread = threadDoc.data();
|
|
349
|
+
const doctorDoc = await db.collection("providers").doc(thread.doctorId).get();
|
|
350
|
+
const doctor = doctorDoc.data();
|
|
351
|
+
|
|
352
|
+
// Payment access check
|
|
353
|
+
if (doctor.paymentEnabled && doctor.pricingModel !== 'free') {
|
|
354
|
+
const accessResult = await checkPaymentAccess({
|
|
355
|
+
providerId: thread.doctorId,
|
|
356
|
+
patientId: user.uid
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
if (!accessResult.hasAccess) {
|
|
360
|
+
jsonResponse(res, 402, {
|
|
361
|
+
error: "Payment required",
|
|
362
|
+
paymentRequired: true,
|
|
363
|
+
paymentConfig: {
|
|
364
|
+
pricingModel: doctor.pricingModel,
|
|
365
|
+
subscriptionPrice: doctor.subscriptionPrice || 0,
|
|
366
|
+
perChatPrice: doctor.perChatPrice || 0,
|
|
367
|
+
currency: doctor.currency || 'usd',
|
|
368
|
+
handle: doctor.handle,
|
|
369
|
+
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Safety check on input
|
|
377
|
+
const topicKey = topic || thread.currentTopic || "general_health_info";
|
|
378
|
+
const topicConfigForSafety = TOPICS[topicKey] || TOPICS[TOPIC_KEYS[0]];
|
|
379
|
+
const inputSafety = await analyzeInput(message, topicConfigForSafety);
|
|
380
|
+
if (inputSafety.blocked) {
|
|
381
|
+
jsonResponse(res, 400, { error: "Message blocked by safety filter" });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Save patient message
|
|
386
|
+
await addMessageToThread(threadId, "patient", message);
|
|
387
|
+
|
|
388
|
+
// Update thread topic if provided
|
|
389
|
+
if (topic) {
|
|
390
|
+
await threadDoc.ref.update({ currentTopic: topic });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Generate AI response (unless doctor has override enabled)
|
|
394
|
+
let aiResponse = "Thank you for your message. The doctor will respond soon.";
|
|
395
|
+
let isEscalated = false;
|
|
396
|
+
|
|
397
|
+
if (!thread.doctorOverride) {
|
|
398
|
+
const threadMessages = await getThreadMessages(threadId, 10);
|
|
399
|
+
const conversationHistory = threadMessages.map(msg => ({
|
|
400
|
+
role: msg.role === "patient" ? "user" : "assistant",
|
|
401
|
+
content: msg.message
|
|
402
|
+
}));
|
|
403
|
+
|
|
404
|
+
const aiTopicKey = topic || thread.currentTopic || "general_health_info";
|
|
405
|
+
const topicConfig = TOPICS[aiTopicKey] || TOPICS[TOPIC_KEYS[0]];
|
|
406
|
+
|
|
407
|
+
// Retrieve doctor examples for personalized responses (Component 5)
|
|
408
|
+
let doctorExamples = [];
|
|
409
|
+
try {
|
|
410
|
+
doctorExamples = await retrieveRelevantExamples({
|
|
411
|
+
doctorId: thread.doctorId,
|
|
412
|
+
topic: topicKey,
|
|
413
|
+
limit: 3
|
|
414
|
+
});
|
|
415
|
+
} catch (err) {
|
|
416
|
+
console.error("[Patient API] Failed to retrieve doctor examples:", err.message);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
aiResponse = await generateResponse({
|
|
420
|
+
topicConfig,
|
|
421
|
+
message,
|
|
422
|
+
provider: doctor,
|
|
423
|
+
conversationHistory,
|
|
424
|
+
doctorExamples
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Safety check on output
|
|
428
|
+
const outputSafety = analyzeOutput(aiResponse, topicConfig);
|
|
429
|
+
if (outputSafety.blocked) {
|
|
430
|
+
aiResponse = "I can't help with that. For medical advice, please contact a licensed clinician.";
|
|
431
|
+
}
|
|
432
|
+
if (inputSafety.escalated) {
|
|
433
|
+
isEscalated = true;
|
|
434
|
+
await threadDoc.ref.update({ isEscalated: true });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Save AI response
|
|
439
|
+
await addMessageToThread(threadId, "ai", aiResponse, { isAiGenerated: true });
|
|
440
|
+
|
|
441
|
+
// Fire-and-forget: Triage classification + draft generation + notifications (Phase 2)
|
|
442
|
+
const allMessages = [
|
|
443
|
+
...(await getThreadMessages(threadId, 10)).map(m => ({
|
|
444
|
+
role: m.role === "patient" ? "user" : "assistant",
|
|
445
|
+
content: m.message || m.content
|
|
446
|
+
}))
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
classifyConversation({ messages: allMessages, isEscalated })
|
|
450
|
+
.then(async (triage) => {
|
|
451
|
+
console.log(`[Triage] Thread ${threadId}: ${triage.category} (${triage.confidence})`);
|
|
452
|
+
const triageUpdate = {
|
|
453
|
+
triageCategory: triage.category,
|
|
454
|
+
triageConfidence: triage.confidence,
|
|
455
|
+
triageReason: triage.reason,
|
|
456
|
+
triageUpdatedAt: admin.firestore.FieldValue.serverTimestamp()
|
|
457
|
+
};
|
|
458
|
+
await threadDoc.ref.update(triageUpdate);
|
|
459
|
+
|
|
460
|
+
// Generate draft for urgent/needs_review (Component 3)
|
|
461
|
+
if (triage.category === "urgent" || triage.category === "needs_review") {
|
|
462
|
+
try {
|
|
463
|
+
let draftExamples = [];
|
|
464
|
+
try {
|
|
465
|
+
draftExamples = await retrieveRelevantExamples({
|
|
466
|
+
doctorId: thread.doctorId,
|
|
467
|
+
topic: topic || thread.currentTopic,
|
|
468
|
+
limit: 3
|
|
469
|
+
});
|
|
470
|
+
} catch (e) { /* ignore */ }
|
|
471
|
+
|
|
472
|
+
const draftTopicKey = topic || thread.currentTopic || "general_health_info";
|
|
473
|
+
const draftResult = await generateDoctorDraft({
|
|
474
|
+
messages: allMessages,
|
|
475
|
+
provider: doctor,
|
|
476
|
+
topic: draftTopicKey,
|
|
477
|
+
triageCategory: triage.category,
|
|
478
|
+
doctorExamples: draftExamples
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
if (draftResult) {
|
|
482
|
+
await threadDoc.ref.update({
|
|
483
|
+
aiDraftResponse: draftResult.draft,
|
|
484
|
+
aiDraftGeneratedAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
485
|
+
aiDraftStatus: "pending"
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
notifyDraftReady({
|
|
489
|
+
doctorId: thread.doctorId,
|
|
490
|
+
threadId,
|
|
491
|
+
patientName: thread.patientName
|
|
492
|
+
}).catch(err => console.error("[Notifications] Draft notify failed:", err.message));
|
|
493
|
+
}
|
|
494
|
+
} catch (draftErr) {
|
|
495
|
+
console.error("[Drafts] Generation failed:", draftErr.message);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Notify escalation (Component 4)
|
|
499
|
+
if (triage.category === "urgent") {
|
|
500
|
+
notifyEscalation({
|
|
501
|
+
doctorId: thread.doctorId,
|
|
502
|
+
threadId,
|
|
503
|
+
patientName: thread.patientName,
|
|
504
|
+
reason: triage.reason
|
|
505
|
+
}).catch(err => console.error("[Notifications] Escalation notify failed:", err.message));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
.catch(err => console.error("[Triage] Classification failed:", err.message));
|
|
510
|
+
|
|
511
|
+
jsonResponse(res, 200, {
|
|
512
|
+
response: aiResponse,
|
|
513
|
+
escalation: isEscalated
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// POST /api/patient/thread/:threadId/read - Mark as read
|
|
519
|
+
if (req.method === "POST" && req.path.match(/^\/patient\/thread\/[^\/]+\/read$/)) {
|
|
520
|
+
const threadId = req.path.split("/")[3];
|
|
521
|
+
|
|
522
|
+
// Verify patient owns this thread
|
|
523
|
+
const threadDoc = await db.collection("threads").doc(threadId).get();
|
|
524
|
+
if (!threadDoc.exists || threadDoc.data().patientId !== user.uid) {
|
|
525
|
+
jsonResponse(res, 404, { error: "Thread not found" });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
await markThreadAsRead(threadId, "patient");
|
|
530
|
+
jsonResponse(res, 200, { success: true });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
jsonResponse(res, 404, { error: "Not found" });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
module.exports = {
|
|
538
|
+
handlePatientAPI,
|
|
539
|
+
getPatient,
|
|
540
|
+
createPatient,
|
|
541
|
+
getOrCreateThread,
|
|
542
|
+
addMessageToThread,
|
|
543
|
+
getThreadMessages,
|
|
544
|
+
markThreadAsRead
|
|
545
|
+
};
|