@ratim818/allyve-wellness-backend 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.example +49 -0
- package/.github/workflows/ci.yml +34 -0
- package/.github/workflows/publish.yml +81 -0
- package/README.md +140 -0
- package/docs/HIPAA_COMPLIANCE.md +141 -0
- package/docs/frontend-api-client.ts +259 -0
- package/package.json +60 -0
- package/src/config/database.ts +45 -0
- package/src/config/index.ts +52 -0
- package/src/middleware/auth.ts +167 -0
- package/src/middleware/security.ts +101 -0
- package/src/migrations/rollback.ts +17 -0
- package/src/migrations/run.ts +17 -0
- package/src/migrations/schema.ts +339 -0
- package/src/migrations/seed.ts +159 -0
- package/src/routes/appointments.ts +293 -0
- package/src/routes/audit.ts +29 -0
- package/src/routes/auth.ts +141 -0
- package/src/routes/health.ts +387 -0
- package/src/server.ts +124 -0
- package/src/services/audit.ts +117 -0
- package/src/services/auth.ts +293 -0
- package/src/services/encryption.ts +76 -0
- package/src/utils/logger.ts +57 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import { v4 as uuidv4 } from "uuid";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { db } from "../config/database.js";
|
|
5
|
+
import { encrypt, decrypt, encryptJSON, decryptJSON } from "../services/encryption.js";
|
|
6
|
+
import { writeAuditLog } from "../services/audit.js";
|
|
7
|
+
import { authenticate, validateSession } from "../middleware/auth.js";
|
|
8
|
+
|
|
9
|
+
export const healthRouter = Router();
|
|
10
|
+
|
|
11
|
+
// All routes require authentication + valid session
|
|
12
|
+
healthRouter.use(authenticate, validateSession);
|
|
13
|
+
|
|
14
|
+
// ── Validation Schemas ──────────────────────────────────
|
|
15
|
+
const healthMetricSchema = z.object({
|
|
16
|
+
metricName: z.string().min(1).max(50),
|
|
17
|
+
value: z.union([z.number(), z.string()]),
|
|
18
|
+
unit: z.string().max(20),
|
|
19
|
+
status: z.enum(["normal", "warning", "critical", "unknown"]),
|
|
20
|
+
trend: z.enum(["rising", "falling", "stable", "fluctuating", "unknown"]).optional(),
|
|
21
|
+
minThreshold: z.number().optional(),
|
|
22
|
+
maxThreshold: z.number().optional(),
|
|
23
|
+
deviceId: z.string().uuid().optional(),
|
|
24
|
+
recordedAt: z.string().datetime().optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const symptomSchema = z.object({
|
|
28
|
+
name: z.string().min(1).max(100),
|
|
29
|
+
severity: z.number().int().min(1).max(10),
|
|
30
|
+
notes: z.string().max(2000).optional(),
|
|
31
|
+
isCardiovascularRelated: z.boolean(),
|
|
32
|
+
recordedAt: z.string().datetime().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const moodSchema = z.object({
|
|
36
|
+
mood: z.enum(["verySad", "sad", "neutral", "happy", "veryHappy", "angry"]),
|
|
37
|
+
notes: z.string().max(2000).optional(),
|
|
38
|
+
anxietyLevel: z.number().int().min(1).max(10).optional(),
|
|
39
|
+
recordedAt: z.string().datetime().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const journalSchema = z.object({
|
|
43
|
+
content: z.string().min(1).max(10000),
|
|
44
|
+
tags: z.array(z.string().max(50)).max(20).optional(),
|
|
45
|
+
painScore: z.number().int().min(1).max(10).optional(),
|
|
46
|
+
anxietyLevel: z.number().int().min(1).max(10).optional(),
|
|
47
|
+
recordedAt: z.string().datetime().optional(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ════════════════════════════════════════════════════════════
|
|
51
|
+
// HEALTH METRICS
|
|
52
|
+
// ════════════════════════════════════════════════════════════
|
|
53
|
+
|
|
54
|
+
// GET /health/metrics — list user's health metrics
|
|
55
|
+
healthRouter.get("/metrics", async (req: Request, res: Response) => {
|
|
56
|
+
try {
|
|
57
|
+
const userId = req.user!.userId;
|
|
58
|
+
const { limit = 50, offset = 0, metric, since } = req.query;
|
|
59
|
+
|
|
60
|
+
let query = db("health_metrics")
|
|
61
|
+
.where("user_id", userId)
|
|
62
|
+
.where("is_deleted", false)
|
|
63
|
+
.orderBy("recorded_at", "desc")
|
|
64
|
+
.limit(Number(limit))
|
|
65
|
+
.offset(Number(offset));
|
|
66
|
+
|
|
67
|
+
if (metric) query = query.where("metric_name", metric as string);
|
|
68
|
+
if (since) query = query.where("recorded_at", ">=", new Date(since as string));
|
|
69
|
+
|
|
70
|
+
const metrics = await query;
|
|
71
|
+
|
|
72
|
+
// Decrypt values before sending
|
|
73
|
+
const decrypted = metrics.map((m: any) => ({
|
|
74
|
+
id: m.id,
|
|
75
|
+
metricName: m.metric_name,
|
|
76
|
+
value: decrypt(m.value_encrypted),
|
|
77
|
+
unit: m.unit,
|
|
78
|
+
status: m.status,
|
|
79
|
+
trend: m.trend,
|
|
80
|
+
minThreshold: m.min_threshold,
|
|
81
|
+
maxThreshold: m.max_threshold,
|
|
82
|
+
deviceId: m.device_id,
|
|
83
|
+
recordedAt: m.recorded_at,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
await writeAuditLog({
|
|
87
|
+
user_id: userId,
|
|
88
|
+
action: "VIEW",
|
|
89
|
+
resource_type: "health_metric",
|
|
90
|
+
details: { count: decrypted.length },
|
|
91
|
+
ip_address: req.ip,
|
|
92
|
+
outcome: "success",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
res.json({ data: decrypted, total: decrypted.length });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
res.status(500).json({ error: "Failed to fetch health metrics" });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// POST /health/metrics — record a new health metric
|
|
102
|
+
healthRouter.post("/metrics", async (req: Request, res: Response) => {
|
|
103
|
+
try {
|
|
104
|
+
const data = healthMetricSchema.parse(req.body);
|
|
105
|
+
const id = uuidv4();
|
|
106
|
+
|
|
107
|
+
await db("health_metrics").insert({
|
|
108
|
+
id,
|
|
109
|
+
user_id: req.user!.userId,
|
|
110
|
+
metric_name: data.metricName,
|
|
111
|
+
value_encrypted: encrypt(String(data.value)),
|
|
112
|
+
unit: data.unit,
|
|
113
|
+
status: data.status,
|
|
114
|
+
trend: data.trend || "unknown",
|
|
115
|
+
min_threshold: data.minThreshold,
|
|
116
|
+
max_threshold: data.maxThreshold,
|
|
117
|
+
device_id: data.deviceId,
|
|
118
|
+
recorded_at: data.recordedAt ? new Date(data.recordedAt) : new Date(),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await writeAuditLog({
|
|
122
|
+
user_id: req.user!.userId,
|
|
123
|
+
action: "CREATE",
|
|
124
|
+
resource_type: "health_metric",
|
|
125
|
+
resource_id: id,
|
|
126
|
+
details: { metricName: data.metricName, status: data.status },
|
|
127
|
+
ip_address: req.ip,
|
|
128
|
+
outcome: "success",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
res.status(201).json({ id, message: "Health metric recorded" });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err instanceof z.ZodError) {
|
|
134
|
+
res.status(400).json({ error: "Validation failed", details: err.errors });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
res.status(500).json({ error: "Failed to record health metric" });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ════════════════════════════════════════════════════════════
|
|
142
|
+
// SYMPTOMS
|
|
143
|
+
// ════════════════════════════════════════════════════════════
|
|
144
|
+
|
|
145
|
+
healthRouter.get("/symptoms", async (req: Request, res: Response) => {
|
|
146
|
+
try {
|
|
147
|
+
const symptoms = await db("symptoms")
|
|
148
|
+
.where("user_id", req.user!.userId)
|
|
149
|
+
.where("is_deleted", false)
|
|
150
|
+
.orderBy("recorded_at", "desc")
|
|
151
|
+
.limit(Number(req.query.limit || 50));
|
|
152
|
+
|
|
153
|
+
const decrypted = symptoms.map((s: any) => ({
|
|
154
|
+
id: s.id,
|
|
155
|
+
name: s.name,
|
|
156
|
+
severity: s.severity,
|
|
157
|
+
notes: s.notes_encrypted ? decrypt(s.notes_encrypted) : null,
|
|
158
|
+
isCardiovascularRelated: s.is_cardiovascular_related,
|
|
159
|
+
recordedAt: s.recorded_at,
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
await writeAuditLog({
|
|
163
|
+
user_id: req.user!.userId,
|
|
164
|
+
action: "VIEW",
|
|
165
|
+
resource_type: "symptom",
|
|
166
|
+
ip_address: req.ip,
|
|
167
|
+
outcome: "success",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
res.json({ data: decrypted });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
res.status(500).json({ error: "Failed to fetch symptoms" });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
healthRouter.post("/symptoms", async (req: Request, res: Response) => {
|
|
177
|
+
try {
|
|
178
|
+
const data = symptomSchema.parse(req.body);
|
|
179
|
+
const id = uuidv4();
|
|
180
|
+
|
|
181
|
+
await db("symptoms").insert({
|
|
182
|
+
id,
|
|
183
|
+
user_id: req.user!.userId,
|
|
184
|
+
name: data.name,
|
|
185
|
+
severity: data.severity,
|
|
186
|
+
notes_encrypted: data.notes ? encrypt(data.notes) : null,
|
|
187
|
+
is_cardiovascular_related: data.isCardiovascularRelated,
|
|
188
|
+
recorded_at: data.recordedAt ? new Date(data.recordedAt) : new Date(),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await writeAuditLog({
|
|
192
|
+
user_id: req.user!.userId,
|
|
193
|
+
action: "CREATE",
|
|
194
|
+
resource_type: "symptom",
|
|
195
|
+
resource_id: id,
|
|
196
|
+
ip_address: req.ip,
|
|
197
|
+
outcome: "success",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
res.status(201).json({ id, message: "Symptom recorded" });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err instanceof z.ZodError) {
|
|
203
|
+
res.status(400).json({ error: "Validation failed", details: err.errors });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
res.status(500).json({ error: "Failed to record symptom" });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ════════════════════════════════════════════════════════════
|
|
211
|
+
// MOOD ENTRIES
|
|
212
|
+
// ════════════════════════════════════════════════════════════
|
|
213
|
+
|
|
214
|
+
healthRouter.get("/mood", async (req: Request, res: Response) => {
|
|
215
|
+
try {
|
|
216
|
+
const entries = await db("mood_entries")
|
|
217
|
+
.where("user_id", req.user!.userId)
|
|
218
|
+
.where("is_deleted", false)
|
|
219
|
+
.orderBy("recorded_at", "desc")
|
|
220
|
+
.limit(Number(req.query.limit || 50));
|
|
221
|
+
|
|
222
|
+
const decrypted = entries.map((e: any) => ({
|
|
223
|
+
id: e.id,
|
|
224
|
+
mood: e.mood,
|
|
225
|
+
notes: e.notes_encrypted ? decrypt(e.notes_encrypted) : null,
|
|
226
|
+
anxietyLevel: e.anxiety_level,
|
|
227
|
+
recordedAt: e.recorded_at,
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
await writeAuditLog({
|
|
231
|
+
user_id: req.user!.userId,
|
|
232
|
+
action: "VIEW",
|
|
233
|
+
resource_type: "mood_entry",
|
|
234
|
+
ip_address: req.ip,
|
|
235
|
+
outcome: "success",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
res.json({ data: decrypted });
|
|
239
|
+
} catch (err) {
|
|
240
|
+
res.status(500).json({ error: "Failed to fetch mood entries" });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
healthRouter.post("/mood", async (req: Request, res: Response) => {
|
|
245
|
+
try {
|
|
246
|
+
const data = moodSchema.parse(req.body);
|
|
247
|
+
const id = uuidv4();
|
|
248
|
+
|
|
249
|
+
await db("mood_entries").insert({
|
|
250
|
+
id,
|
|
251
|
+
user_id: req.user!.userId,
|
|
252
|
+
mood: data.mood,
|
|
253
|
+
notes_encrypted: data.notes ? encrypt(data.notes) : null,
|
|
254
|
+
anxiety_level: data.anxietyLevel,
|
|
255
|
+
recorded_at: data.recordedAt ? new Date(data.recordedAt) : new Date(),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await writeAuditLog({
|
|
259
|
+
user_id: req.user!.userId,
|
|
260
|
+
action: "CREATE",
|
|
261
|
+
resource_type: "mood_entry",
|
|
262
|
+
resource_id: id,
|
|
263
|
+
ip_address: req.ip,
|
|
264
|
+
outcome: "success",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
res.status(201).json({ id, message: "Mood entry recorded" });
|
|
268
|
+
} catch (err) {
|
|
269
|
+
if (err instanceof z.ZodError) {
|
|
270
|
+
res.status(400).json({ error: "Validation failed", details: err.errors });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
res.status(500).json({ error: "Failed to record mood" });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ════════════════════════════════════════════════════════════
|
|
278
|
+
// JOURNAL ENTRIES
|
|
279
|
+
// ════════════════════════════════════════════════════════════
|
|
280
|
+
|
|
281
|
+
healthRouter.get("/journal", async (req: Request, res: Response) => {
|
|
282
|
+
try {
|
|
283
|
+
const entries = await db("journal_entries")
|
|
284
|
+
.where("user_id", req.user!.userId)
|
|
285
|
+
.where("is_deleted", false)
|
|
286
|
+
.orderBy("recorded_at", "desc")
|
|
287
|
+
.limit(Number(req.query.limit || 50));
|
|
288
|
+
|
|
289
|
+
const decrypted = entries.map((e: any) => ({
|
|
290
|
+
id: e.id,
|
|
291
|
+
content: decrypt(e.content_encrypted),
|
|
292
|
+
tags: e.tags,
|
|
293
|
+
painScore: e.pain_score,
|
|
294
|
+
anxietyLevel: e.anxiety_level,
|
|
295
|
+
recordedAt: e.recorded_at,
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
await writeAuditLog({
|
|
299
|
+
user_id: req.user!.userId,
|
|
300
|
+
action: "VIEW",
|
|
301
|
+
resource_type: "journal_entry",
|
|
302
|
+
ip_address: req.ip,
|
|
303
|
+
outcome: "success",
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
res.json({ data: decrypted });
|
|
307
|
+
} catch (err) {
|
|
308
|
+
res.status(500).json({ error: "Failed to fetch journal entries" });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
healthRouter.post("/journal", async (req: Request, res: Response) => {
|
|
313
|
+
try {
|
|
314
|
+
const data = journalSchema.parse(req.body);
|
|
315
|
+
const id = uuidv4();
|
|
316
|
+
|
|
317
|
+
await db("journal_entries").insert({
|
|
318
|
+
id,
|
|
319
|
+
user_id: req.user!.userId,
|
|
320
|
+
content_encrypted: encrypt(data.content),
|
|
321
|
+
tags: data.tags || [],
|
|
322
|
+
pain_score: data.painScore,
|
|
323
|
+
anxiety_level: data.anxietyLevel,
|
|
324
|
+
recorded_at: data.recordedAt ? new Date(data.recordedAt) : new Date(),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await writeAuditLog({
|
|
328
|
+
user_id: req.user!.userId,
|
|
329
|
+
action: "CREATE",
|
|
330
|
+
resource_type: "journal_entry",
|
|
331
|
+
resource_id: id,
|
|
332
|
+
ip_address: req.ip,
|
|
333
|
+
outcome: "success",
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
res.status(201).json({ id, message: "Journal entry recorded" });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
if (err instanceof z.ZodError) {
|
|
339
|
+
res.status(400).json({ error: "Validation failed", details: err.errors });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
res.status(500).json({ error: "Failed to record journal entry" });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ════════════════════════════════════════════════════════════
|
|
347
|
+
// CARDIOVASCULAR RISK
|
|
348
|
+
// ════════════════════════════════════════════════════════════
|
|
349
|
+
|
|
350
|
+
healthRouter.get("/cardiovascular-risk", async (req: Request, res: Response) => {
|
|
351
|
+
try {
|
|
352
|
+
const latest = await db("cardiovascular_risks")
|
|
353
|
+
.where("user_id", req.user!.userId)
|
|
354
|
+
.where("is_deleted", false)
|
|
355
|
+
.orderBy("assessed_at", "desc")
|
|
356
|
+
.first();
|
|
357
|
+
|
|
358
|
+
if (!latest) {
|
|
359
|
+
res.json({ data: null });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await writeAuditLog({
|
|
364
|
+
user_id: req.user!.userId,
|
|
365
|
+
action: "VIEW",
|
|
366
|
+
resource_type: "cardiovascular_risk",
|
|
367
|
+
resource_id: latest.id,
|
|
368
|
+
ip_address: req.ip,
|
|
369
|
+
outcome: "success",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
res.json({
|
|
373
|
+
data: {
|
|
374
|
+
id: latest.id,
|
|
375
|
+
overallScore: latest.overall_score,
|
|
376
|
+
factors: decryptJSON(latest.factors_encrypted),
|
|
377
|
+
trend: latest.trend,
|
|
378
|
+
recommendations: latest.recommendations_encrypted
|
|
379
|
+
? decryptJSON(latest.recommendations_encrypted)
|
|
380
|
+
: [],
|
|
381
|
+
assessedAt: latest.assessed_at,
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
} catch (err) {
|
|
385
|
+
res.status(500).json({ error: "Failed to fetch cardiovascular risk" });
|
|
386
|
+
}
|
|
387
|
+
});
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { config } from "./config/index.js";
|
|
3
|
+
import { testConnection, closeConnection } from "./config/database.js";
|
|
4
|
+
import { applySecurityMiddleware } from "./middleware/security.js";
|
|
5
|
+
import { authRouter } from "./routes/auth.js";
|
|
6
|
+
import { healthRouter } from "./routes/health.js";
|
|
7
|
+
import { appointmentRouter, shareRouter } from "./routes/appointments.js";
|
|
8
|
+
import { auditRouter } from "./routes/audit.js";
|
|
9
|
+
import { logger } from "./utils/logger.js";
|
|
10
|
+
|
|
11
|
+
const app = express();
|
|
12
|
+
const API = `/api/${config.apiVersion}`;
|
|
13
|
+
|
|
14
|
+
// ── Security Middleware ──────────────────────────────────
|
|
15
|
+
applySecurityMiddleware(app);
|
|
16
|
+
|
|
17
|
+
// ── Health Check (no auth required) ──────────────────────
|
|
18
|
+
app.get("/health", (_req, res) => {
|
|
19
|
+
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ── API Routes ───────────────────────────────────────────
|
|
23
|
+
app.use(`${API}/auth`, authRouter);
|
|
24
|
+
app.use(`${API}/health`, healthRouter);
|
|
25
|
+
app.use(`${API}/appointments`, appointmentRouter);
|
|
26
|
+
app.use(`${API}/share`, shareRouter);
|
|
27
|
+
app.use(`${API}/audit`, auditRouter);
|
|
28
|
+
|
|
29
|
+
// ── API Documentation endpoint ───────────────────────────
|
|
30
|
+
app.get(`${API}`, (_req, res) => {
|
|
31
|
+
res.json({
|
|
32
|
+
name: "Allyve Wellness AI API",
|
|
33
|
+
version: config.apiVersion,
|
|
34
|
+
hipaaCompliant: true,
|
|
35
|
+
endpoints: {
|
|
36
|
+
auth: {
|
|
37
|
+
"POST /auth/register": "Create new account",
|
|
38
|
+
"POST /auth/login": "Login and get tokens",
|
|
39
|
+
"POST /auth/refresh": "Refresh access token",
|
|
40
|
+
"POST /auth/logout": "Logout and revoke session",
|
|
41
|
+
"GET /auth/me": "Get current user profile",
|
|
42
|
+
},
|
|
43
|
+
health: {
|
|
44
|
+
"GET /health/metrics": "List health metrics",
|
|
45
|
+
"POST /health/metrics": "Record health metric",
|
|
46
|
+
"GET /health/symptoms": "List symptoms",
|
|
47
|
+
"POST /health/symptoms": "Record symptom",
|
|
48
|
+
"GET /health/mood": "List mood entries",
|
|
49
|
+
"POST /health/mood": "Record mood",
|
|
50
|
+
"GET /health/journal": "List journal entries",
|
|
51
|
+
"POST /health/journal": "Record journal entry",
|
|
52
|
+
"GET /health/cardiovascular-risk": "Get latest CV risk",
|
|
53
|
+
},
|
|
54
|
+
appointments: {
|
|
55
|
+
"GET /appointments": "List appointments",
|
|
56
|
+
"POST /appointments": "Create appointment",
|
|
57
|
+
"PUT /appointments/:id": "Update appointment",
|
|
58
|
+
"DELETE /appointments/:id": "Soft-delete appointment",
|
|
59
|
+
},
|
|
60
|
+
sharing: {
|
|
61
|
+
"GET /share": "List shared data records",
|
|
62
|
+
"POST /share": "Share data with provider/family",
|
|
63
|
+
"POST /share/:id/revoke": "Revoke data sharing",
|
|
64
|
+
},
|
|
65
|
+
audit: {
|
|
66
|
+
"GET /audit/logs": "Query audit trail (admin only)",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── 404 Handler ──────────────────────────────────────────
|
|
73
|
+
app.use((_req, res) => {
|
|
74
|
+
res.status(404).json({ error: "Endpoint not found" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── Global Error Handler ─────────────────────────────────
|
|
78
|
+
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
79
|
+
logger.error("Unhandled error:", err);
|
|
80
|
+
// Never leak internal errors to client (HIPAA: minimum necessary)
|
|
81
|
+
res.status(500).json({ error: "Internal server error" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── Start Server ─────────────────────────────────────────
|
|
85
|
+
async function start() {
|
|
86
|
+
try {
|
|
87
|
+
await testConnection();
|
|
88
|
+
logger.info("Database connected");
|
|
89
|
+
|
|
90
|
+
app.listen(config.port, () => {
|
|
91
|
+
logger.info(`
|
|
92
|
+
┌─────────────────────────────────────────────┐
|
|
93
|
+
│ 🩺 Allyve Wellness AI — Backend Server │
|
|
94
|
+
│ │
|
|
95
|
+
│ Environment : ${config.env.padEnd(28)}│
|
|
96
|
+
│ Port : ${String(config.port).padEnd(28)}│
|
|
97
|
+
│ API Base : ${API.padEnd(28)}│
|
|
98
|
+
│ HIPAA Mode : ENABLED │
|
|
99
|
+
│ Encryption : AES-256-GCM │
|
|
100
|
+
│ Auth : JWT + bcrypt (12 rounds) │
|
|
101
|
+
│ Audit Trail : PostgreSQL (immutable) │
|
|
102
|
+
└─────────────────────────────────────────────┘
|
|
103
|
+
`);
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
logger.error("Failed to start server:", err);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Graceful shutdown
|
|
112
|
+
process.on("SIGTERM", async () => {
|
|
113
|
+
logger.info("SIGTERM received. Shutting down...");
|
|
114
|
+
await closeConnection();
|
|
115
|
+
process.exit(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
process.on("SIGINT", async () => {
|
|
119
|
+
logger.info("SIGINT received. Shutting down...");
|
|
120
|
+
await closeConnection();
|
|
121
|
+
process.exit(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
start();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { db } from "../config/database.js";
|
|
2
|
+
import { logger } from "../utils/logger.js";
|
|
3
|
+
|
|
4
|
+
// ─── HIPAA AUDIT TRAIL ──────────────────────────────────────
|
|
5
|
+
// HIPAA §164.312(b) — Audit controls
|
|
6
|
+
// HIPAA §164.530(j) — 6-year retention requirement
|
|
7
|
+
//
|
|
8
|
+
// Records WHO accessed WHAT, WHEN, from WHERE, and the OUTCOME.
|
|
9
|
+
// Stored in an append-only table. No UPDATE/DELETE allowed.
|
|
10
|
+
// ─────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type AuditAction =
|
|
13
|
+
| "LOGIN"
|
|
14
|
+
| "LOGOUT"
|
|
15
|
+
| "LOGIN_FAILED"
|
|
16
|
+
| "SESSION_TIMEOUT"
|
|
17
|
+
| "VIEW"
|
|
18
|
+
| "CREATE"
|
|
19
|
+
| "UPDATE"
|
|
20
|
+
| "DELETE"
|
|
21
|
+
| "EXPORT"
|
|
22
|
+
| "SHARE"
|
|
23
|
+
| "REVOKE_SHARE"
|
|
24
|
+
| "DOWNLOAD"
|
|
25
|
+
| "PRINT"
|
|
26
|
+
| "CONSENT_GIVEN"
|
|
27
|
+
| "CONSENT_REVOKED"
|
|
28
|
+
| "PASSWORD_CHANGE"
|
|
29
|
+
| "PASSWORD_RESET"
|
|
30
|
+
| "ROLE_CHANGE"
|
|
31
|
+
| "EMERGENCY_ACCESS";
|
|
32
|
+
|
|
33
|
+
export type AuditResource =
|
|
34
|
+
| "user"
|
|
35
|
+
| "session"
|
|
36
|
+
| "health_metric"
|
|
37
|
+
| "ecg_data"
|
|
38
|
+
| "symptom"
|
|
39
|
+
| "mood_entry"
|
|
40
|
+
| "journal_entry"
|
|
41
|
+
| "appointment"
|
|
42
|
+
| "provider_contact"
|
|
43
|
+
| "shared_data"
|
|
44
|
+
| "health_report"
|
|
45
|
+
| "cardiovascular_risk"
|
|
46
|
+
| "wearable_device";
|
|
47
|
+
|
|
48
|
+
export interface AuditEntry {
|
|
49
|
+
user_id: string | null;
|
|
50
|
+
action: AuditAction;
|
|
51
|
+
resource_type: AuditResource;
|
|
52
|
+
resource_id?: string;
|
|
53
|
+
details?: Record<string, unknown>;
|
|
54
|
+
ip_address?: string;
|
|
55
|
+
user_agent?: string;
|
|
56
|
+
outcome: "success" | "failure" | "denied";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write an immutable audit log entry.
|
|
61
|
+
* This function never throws — audit failures are logged but don't break the app.
|
|
62
|
+
*/
|
|
63
|
+
export async function writeAuditLog(entry: AuditEntry): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
await db("audit_logs").insert({
|
|
66
|
+
user_id: entry.user_id,
|
|
67
|
+
action: entry.action,
|
|
68
|
+
resource_type: entry.resource_type,
|
|
69
|
+
resource_id: entry.resource_id || null,
|
|
70
|
+
details: entry.details ? JSON.stringify(entry.details) : null,
|
|
71
|
+
ip_address: entry.ip_address || null,
|
|
72
|
+
user_agent: entry.user_agent || null,
|
|
73
|
+
outcome: entry.outcome,
|
|
74
|
+
created_at: new Date(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Also write to file-based audit log for redundancy
|
|
78
|
+
logger.info("AUDIT", {
|
|
79
|
+
userId: entry.user_id,
|
|
80
|
+
action: entry.action,
|
|
81
|
+
resource: entry.resource_type,
|
|
82
|
+
resourceId: entry.resource_id,
|
|
83
|
+
outcome: entry.outcome,
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Audit failures must never silently pass — log to stderr as critical
|
|
87
|
+
logger.error("CRITICAL: Failed to write audit log", {
|
|
88
|
+
error: (err as Error).message,
|
|
89
|
+
entry: { ...entry, details: undefined }, // Don't log details on error
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Query audit logs with filters. Only accessible to admin/compliance roles.
|
|
96
|
+
*/
|
|
97
|
+
export async function queryAuditLogs(filters: {
|
|
98
|
+
userId?: string;
|
|
99
|
+
action?: AuditAction;
|
|
100
|
+
resourceType?: AuditResource;
|
|
101
|
+
startDate?: Date;
|
|
102
|
+
endDate?: Date;
|
|
103
|
+
limit?: number;
|
|
104
|
+
offset?: number;
|
|
105
|
+
}) {
|
|
106
|
+
let query = db("audit_logs").orderBy("created_at", "desc");
|
|
107
|
+
|
|
108
|
+
if (filters.userId) query = query.where("user_id", filters.userId);
|
|
109
|
+
if (filters.action) query = query.where("action", filters.action);
|
|
110
|
+
if (filters.resourceType) query = query.where("resource_type", filters.resourceType);
|
|
111
|
+
if (filters.startDate) query = query.where("created_at", ">=", filters.startDate);
|
|
112
|
+
if (filters.endDate) query = query.where("created_at", "<=", filters.endDate);
|
|
113
|
+
|
|
114
|
+
query = query.limit(filters.limit || 100).offset(filters.offset || 0);
|
|
115
|
+
|
|
116
|
+
return query;
|
|
117
|
+
}
|