mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
merge: integrate the v1.23 data export and encryption-status wave
This commit is contained in:
commit
268c5cef05
29 files changed
+2346
-125
No files matched your search
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/rotate
|
||||
*
|
||||
* v1.23 — admin-triggered encryption-key rotation. Enqueues the
|
||||
* `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted-
|
||||
* column corpus to the configured ACTIVE key id. The CLI
|
||||
* (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a
|
||||
* convenience that reuses the same column registry and is safe by construction:
|
||||
*
|
||||
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active
|
||||
* key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env +
|
||||
* redeploy act.
|
||||
* - IDEMPOTENT: rows already on the active key are skipped; the singleton key
|
||||
* coalesces duplicate triggers into one queued run.
|
||||
*
|
||||
* Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS
|
||||
* `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a
|
||||
* high-blast-radius action. An admin without a second factor cannot use the
|
||||
* button and falls back to the CLI (this is intentional: step-up cannot be
|
||||
* softened, and the CLI is the documented canonical path).
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireAdmin,
|
||||
requireFreshMfa,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
} from "@/lib/jobs/encryption-key-rotate";
|
||||
import { getActiveKeyId } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Cookie-only admin, then a fresh second factor. Both resolution paths are
|
||||
// cookie-only, so a Bearer token cannot reach either.
|
||||
await requireAdmin();
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
annotate({ action: { name: "admin.encryption.rotate.requested" } });
|
||||
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return apiError("Background worker is not available", 503);
|
||||
}
|
||||
|
||||
const jobId = await boss.send(
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
{ requestedByUserId: user.id, enqueuedAt: new Date().toISOString() },
|
||||
{
|
||||
// Coalesce duplicate triggers: at most one queued run at a time.
|
||||
singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
retryLimit: 2,
|
||||
retryDelay: 60,
|
||||
},
|
||||
);
|
||||
|
||||
await auditLog("admin.encryption.rotate.requested", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { activeKeyId: getActiveKeyId(), enqueued: Boolean(jobId) },
|
||||
});
|
||||
|
||||
annotate({ meta: { rotate_enqueued: Boolean(jobId) } });
|
||||
|
||||
// A null jobId means the singleton coalesced this trigger onto an already-
|
||||
// queued run — still "accepted" from the operator's point of view.
|
||||
return apiSuccess({ accepted: true, alreadyQueued: !jobId });
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* GET /api/admin/encryption/status
|
||||
*
|
||||
* v1.23 — read-only admin view of encryption coverage + rotation progress.
|
||||
* Buckets every registered encrypted column's rows by key id (the same scan the
|
||||
* rotation tooling does), so an operator can SEE whether a rotation finished
|
||||
* before dropping a legacy key, instead of guessing.
|
||||
*
|
||||
* Returns ONLY key IDS (operator-chosen labels like `v1` / `v2`) and ROW
|
||||
* COUNTS — never key material. The configured-key set is surfaced as a count.
|
||||
*
|
||||
* Auth: cookie-only `requireAdmin` (a Bearer token can never reach admin). The
|
||||
* scan is on-demand with a short in-process cache so repeated panel renders
|
||||
* don't re-scan the corpus each time; the rotation run state is read from the
|
||||
* audit trail (DB-backed, so it is correct across the web/worker processes).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getConfiguredKeyIds } from "@/lib/crypto";
|
||||
import {
|
||||
scanCorpus,
|
||||
type CorpusClient,
|
||||
type CorpusScan,
|
||||
} from "@/lib/crypto/encryption-corpus";
|
||||
|
||||
const SCAN_CACHE_TTL_MS = 15_000;
|
||||
let scanCache: { at: number; scan: CorpusScan } | null = null;
|
||||
|
||||
interface RotationState {
|
||||
state: "idle" | "running" | "completed" | "failed";
|
||||
lastRequestedAt: string | null;
|
||||
lastCompletedAt: string | null;
|
||||
lastResult: { scanned: number; rotated: number; errors: number } | null;
|
||||
}
|
||||
|
||||
async function readRotationState(): Promise<RotationState> {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
action: {
|
||||
in: [
|
||||
"admin.encryption.rotate.requested",
|
||||
"admin.encryption.rotate.completed",
|
||||
"admin.encryption.rotate.failed",
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
select: { action: true, createdAt: true, details: true },
|
||||
});
|
||||
|
||||
const requested = rows.find(
|
||||
(r) => r.action === "admin.encryption.rotate.requested",
|
||||
);
|
||||
const finished = rows.find(
|
||||
(r) =>
|
||||
r.action === "admin.encryption.rotate.completed" ||
|
||||
r.action === "admin.encryption.rotate.failed",
|
||||
);
|
||||
|
||||
let lastResult: RotationState["lastResult"] = null;
|
||||
if (
|
||||
finished?.action === "admin.encryption.rotate.completed" &&
|
||||
finished.details
|
||||
) {
|
||||
try {
|
||||
const d = JSON.parse(finished.details) as {
|
||||
scanned?: number;
|
||||
rotated?: number;
|
||||
errors?: number;
|
||||
};
|
||||
lastResult = {
|
||||
scanned: d.scanned ?? 0,
|
||||
rotated: d.rotated ?? 0,
|
||||
errors: d.errors ?? 0,
|
||||
};
|
||||
} catch {
|
||||
lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
// "running" iff the most recent request is newer than the most recent
|
||||
// completion/failure.
|
||||
let state: RotationState["state"] = "idle";
|
||||
if (requested && (!finished || requested.createdAt > finished.createdAt)) {
|
||||
state = "running";
|
||||
} else if (finished?.action === "admin.encryption.rotate.completed") {
|
||||
state = "completed";
|
||||
} else if (finished?.action === "admin.encryption.rotate.failed") {
|
||||
state = "failed";
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
lastRequestedAt: requested?.createdAt.toISOString() ?? null,
|
||||
lastCompletedAt: finished?.createdAt.toISOString() ?? null,
|
||||
lastResult,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.status" } });
|
||||
|
||||
const now = Date.now();
|
||||
let scan: CorpusScan;
|
||||
if (scanCache && now - scanCache.at < SCAN_CACHE_TTL_MS) {
|
||||
scan = scanCache.scan;
|
||||
} else {
|
||||
scan = await scanCorpus(prisma as unknown as CorpusClient);
|
||||
scanCache = { at: now, scan };
|
||||
}
|
||||
|
||||
const rotation = await readRotationState();
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
encryption_total_rows: scan.totalRows,
|
||||
encryption_stale_rows: scan.staleRows,
|
||||
encryption_rotation_complete: scan.rotationComplete,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
activeKeyId: scan.activeKeyId,
|
||||
// COUNT only — never the key material.
|
||||
configuredKeyCount: getConfiguredKeyIds().length,
|
||||
rotationComplete: scan.rotationComplete,
|
||||
totalRows: scan.totalRows,
|
||||
activeRows: scan.activeRows,
|
||||
staleRows: scan.staleRows,
|
||||
columns: scan.columns,
|
||||
rotation,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* v1.23 — POST /api/export/encrypted unit coverage.
|
||||
*
|
||||
* Mock-based. Pins the step-up gating contract (single-factor accounts export
|
||||
* without step-up; MFA accounts need a fresh second factor) and that the
|
||||
* passphrase produces a decryptable HLX1 archive — never a plaintext body.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
session: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
lookupIpLocation: vi.fn(),
|
||||
lookupIpAsn: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { POST } from "../route";
|
||||
import { decryptArchive } from "@/lib/export/passphrase-archive";
|
||||
|
||||
const PASSPHRASE = "a-strong-passphrase-123";
|
||||
|
||||
function mkReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/export/encrypted", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: Date.now() + 3600_000,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/export/encrypted", () => {
|
||||
it("returns a decryptable HLX1 archive for a single-factor account", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(buf.subarray(0, 4).toString("ascii")).toBe("HLX1");
|
||||
const plaintext = await decryptArchive(buf, PASSPHRASE);
|
||||
const payload = JSON.parse(plaintext);
|
||||
expect(payload).toHaveProperty("schemaVersion");
|
||||
expect(payload).toHaveProperty("measurements");
|
||||
});
|
||||
|
||||
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
// No fresh mfaVerifiedAt on the session row.
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("allows an MFA account with a fresh second factor", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
|
||||
expect(res.status).toBe(200);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(await decryptArchive(buf, PASSPHRASE)).toContain("schemaVersion");
|
||||
});
|
||||
|
||||
it("rejects a too-short passphrase with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
|
||||
const res = await POST(mkReq({ passphrase: "short" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* POST /api/export/encrypted
|
||||
*
|
||||
* v1.23 — passphrase-encrypted variant of the full-backup export. Returns the
|
||||
* same restore-compatible payload as `GET /api/export/full-backup`, but sealed
|
||||
* into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase
|
||||
* the caller supplies in the request body. The binary archive is returned as
|
||||
* `application/octet-stream`.
|
||||
*
|
||||
* SECURITY:
|
||||
* - Step-up: when the account has a second factor enrolled, this is gated by
|
||||
* `requireFreshMfa` — exporting the whole record is a sensitive action.
|
||||
* Single-factor accounts fall back to a normal authenticated session.
|
||||
* - The passphrase NEVER hits a log or wide-event: it is read off the parsed
|
||||
* body, passed straight into the KDF, and never `annotate()`d. The egress
|
||||
* redaction denylist already scrubs `/passphrase/i` (key-name) and the
|
||||
* `passphrase=` query-string form as defence in depth.
|
||||
* - There is NO server-side recovery — the passphrase is not stored. A
|
||||
* forgotten passphrase means the archive is unrecoverable; the UI says so.
|
||||
*
|
||||
* Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh
|
||||
* second factor for MFA accounts (Bearer can never satisfy step-up).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) — same bucket as the
|
||||
* plaintext export so the encrypted variant cannot be used to bypass the cap.
|
||||
* Audit: `user.export.encrypted` with the row counts (never the passphrase).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
apiHandler,
|
||||
requireAuth,
|
||||
requireFreshMfa,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import {
|
||||
encryptArchive,
|
||||
MIN_EXPORT_PASSPHRASE_LENGTH,
|
||||
} from "@/lib/export/passphrase-archive";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const encryptedExportSchema = z
|
||||
.object({
|
||||
passphrase: z
|
||||
.string()
|
||||
.min(MIN_EXPORT_PASSPHRASE_LENGTH)
|
||||
// A generous upper bound — a passphrase, not a file. Keeps the KDF input
|
||||
// bounded.
|
||||
.max(1024),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Resolve the session first (cookie or Bearer), then escalate to a fresh
|
||||
// second factor when the account has one enrolled. A single-factor account
|
||||
// cannot produce a fresh-MFA proof, so gating it unconditionally would lock
|
||||
// it out of its own encrypted export — the step-up applies to MFA users.
|
||||
const auth = await requireAuth();
|
||||
if (auth.user.totpConfirmedAt) {
|
||||
await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
}
|
||||
const user = auth.user;
|
||||
annotate({ action: { name: "user.export.encrypted" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
// The body is a single passphrase string — small and bounded. 8 KB is far
|
||||
// above any legitimate passphrase while rejecting a large body before parse.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = encryptedExportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error);
|
||||
}
|
||||
const { passphrase } = parsed.data;
|
||||
|
||||
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
|
||||
|
||||
// Encrypt the JSON bytes. The passphrase goes no further than the KDF.
|
||||
const archive = await encryptArchive(JSON.stringify(payload), passphrase);
|
||||
|
||||
await auditLog("user.export.encrypted", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { counts, format: "HLX1" },
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_measurements_count: counts.measurements,
|
||||
export_medications_count: counts.medications,
|
||||
export_intake_count: counts.intakeEvents,
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
export_archive_bytes: archive.byteLength,
|
||||
},
|
||||
});
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
|
||||
// self-contained `.hlx` archive openable with the user's passphrase via
|
||||
// scripts/decrypt-export.ts.
|
||||
return new NextResponse(new Uint8Array(archive), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.hlx"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import { buildCycleBackupSection } from "@/lib/cycle/backup";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -35,125 +33,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries, cycle] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
// v1.4.41 W-DELETED-2 — soft-deleted rows are excluded from the
|
||||
// user-initiated backup bundle so the round-trip via admin
|
||||
// restore does not resurrect deleted measurements.
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
}),
|
||||
// Explicit `select` on the schedule columns — see the matching
|
||||
// comment in `medications/route.ts` for the rationale (the
|
||||
// integration testbed has an unmigrated `days_of_week` column on
|
||||
// the schema branch we ship alongside).
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
label: true,
|
||||
dose: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows (mirrors the measurement
|
||||
// `deletedAt: null` filter above on this user-facing export).
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
}),
|
||||
// v1.15.0 — cycle tables (profile + observed spans + day-logs). The
|
||||
// shared helper carries `notesEncrypted` verbatim (never decrypted).
|
||||
buildCycleBackupSection(prisma, user.id),
|
||||
]);
|
||||
|
||||
// Shape mirrors the pg-boss `data-backup` worker exactly so the same
|
||||
// `parseBackupPayload()` validator round-trips both blobs. Keep these
|
||||
// two writers in sync — `src/lib/jobs/reminder-worker.ts` is the
|
||||
// canonical reference.
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
measurements: measurements.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
source: m.source,
|
||||
// v1.23 — decrypt the note for the human-readable backup; an admin
|
||||
// restore re-encrypts it on re-insert.
|
||||
notes: readNote(m.notesEncrypted, m.notes),
|
||||
})),
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
})),
|
||||
})),
|
||||
intakeEvents: intakeEvents.map((e) => ({
|
||||
medication: e.medication.name,
|
||||
scheduledFor: e.scheduledFor.toISOString(),
|
||||
takenAt: e.takenAt?.toISOString() ?? null,
|
||||
skipped: e.skipped,
|
||||
source: e.source,
|
||||
})),
|
||||
moodEntries: moodEntries.map((e) => ({
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags,
|
||||
source: e.source,
|
||||
loggedAt: e.moodLoggedAt.toISOString(),
|
||||
})),
|
||||
// v1.15.0 — cycle slice (profile + observed spans + day-logs).
|
||||
cycleProfile: cycle.cycleProfile,
|
||||
cycles: cycle.cycles,
|
||||
cycleDayLogs: cycle.cycleDayLogs,
|
||||
};
|
||||
// v1.23 — the payload builder is shared with the passphrase-encrypted
|
||||
// export route so both emit the byte-for-byte same restore-compatible shape.
|
||||
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
|
||||
|
||||
await auditLog("user.export.full-backup", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
counts: {
|
||||
measurements: measurements.length,
|
||||
medications: medications.length,
|
||||
intakeEvents: intakeEvents.length,
|
||||
moodEntries: moodEntries.length,
|
||||
cycles: cycle.cycles.length,
|
||||
cycleDayLogs: cycle.cycleDayLogs.length,
|
||||
},
|
||||
},
|
||||
details: { counts },
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_measurements_count: measurements.length,
|
||||
export_medications_count: medications.length,
|
||||
export_intake_count: intakeEvents.length,
|
||||
export_mood_count: moodEntries.length,
|
||||
export_cycle_count: cycle.cycles.length,
|
||||
export_cycle_day_log_count: cycle.cycleDayLogs.length,
|
||||
export_measurements_count: counts.measurements,
|
||||
export_medications_count: counts.medications,
|
||||
export_intake_count: counts.intakeEvents,
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user