@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.
@@ -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
+ }