mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
docs(planning): mark phase B3 (host-load chart) complete
Host-load chart above /admin/system-status renders the last 2h of load1, memory %, and disk-IO % from a per-minute pg-boss sampler. Schema migration 0032_host_metric, /api/admin/host-metrics endpoint, HostMetricsChart component (Recharts wrapper, next/dynamic from the consumer), system-status section wired with the chart on top, EN+DE i18n keys, 13 unit + 6 integration tests. Three commits landed cleanly under their own titles (db, jobs, api); the chart + wiring + i18n got absorbed into another agent's commit due to a parallel-agent staging race during the verification gate's stash/restore loop. Functionally everything is on origin/main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8ac5602837
commit
226cac4c02
8 files changed
+1178
-116
No files matched your search
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Unit-level smoke for the per-type export endpoints
|
||||
* (`/api/export/{measurements,medications,mood}` and
|
||||
* `/api/export/full-backup`).
|
||||
*
|
||||
* Exercises the auth gate, content-type, content-disposition, audit-log
|
||||
* action name, and rate-limit wiring. The full DB round-trip lives in
|
||||
* the integration suite (`tests/integration/export-per-type.test.ts`).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: 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(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
|
||||
user: { id: "user-1", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkReq(url: string): NextRequest {
|
||||
return new NextRequest(url, { method: "GET" });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/export/measurements", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const { GET } = await import("../measurements/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/measurements"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns text/csv with attachment disposition on success", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
glucoseContext: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const { GET } = await import("../measurements/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/measurements"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toMatch(/text\/csv/);
|
||||
expect(res.headers.get("content-disposition")).toMatch(
|
||||
/attachment; filename="healthlog-measurements-/,
|
||||
);
|
||||
expect(res.headers.get("content-disposition")).toMatch(/\.csv"/);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("WEIGHT,80,kg");
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.export.measurements",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("honours since/until query params via measuredAt range", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const { GET } = await import("../measurements/route");
|
||||
await GET(
|
||||
mkReq(
|
||||
"http://localhost/api/export/measurements?since=2026-04-01&until=2026-05-01",
|
||||
),
|
||||
);
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
measuredAt: expect.objectContaining({
|
||||
gte: new Date("2026-04-01"),
|
||||
lte: new Date("2026-05-01"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export/medications", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const { GET } = await import("../medications/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/medications"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("appends intake-history section when intake=true", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
name: "Aspirin",
|
||||
dose: "100mg",
|
||||
active: true,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
label: "Morning",
|
||||
dose: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
medication: { name: "Aspirin" },
|
||||
scheduledFor: new Date("2026-05-01T08:00:00.000Z"),
|
||||
takenAt: new Date("2026-05-01T08:05:00.000Z"),
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const { GET } = await import("../medications/route");
|
||||
const res = await GET(
|
||||
mkReq("http://localhost/api/export/medications?intake=true"),
|
||||
);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("Aspirin");
|
||||
// The intake-history section is delimited so a downstream tool can split.
|
||||
expect(body).toContain("# Intake history");
|
||||
expect(body).toContain("scheduledFor");
|
||||
});
|
||||
|
||||
it("omits intake-history section when intake=false", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ name: "X", dose: "1", active: true, schedules: [] },
|
||||
] as never);
|
||||
|
||||
const { GET } = await import("../medications/route");
|
||||
const res = await GET(
|
||||
mkReq("http://localhost/api/export/medications?intake=false"),
|
||||
);
|
||||
const body = await res.text();
|
||||
expect(body).not.toContain("# Intake history");
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export/mood", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const { GET } = await import("../mood/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/mood"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns text/csv with mood rows on success", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{
|
||||
date: "2026-05-01",
|
||||
mood: "good",
|
||||
score: 4,
|
||||
tags: null,
|
||||
source: "WEB",
|
||||
moodLoggedAt: new Date("2026-05-01T20:00:00.000Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const { GET } = await import("../mood/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/mood"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toMatch(/text\/csv/);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("good");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.export.mood",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export/full-backup", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const { GET } = await import("../full-backup/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns a JSON backup that matches the canonical schema", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const { GET } = await import("../full-backup/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toMatch(/application\/json/);
|
||||
expect(res.headers.get("content-disposition")).toMatch(
|
||||
/attachment; filename="healthlog-backup-/,
|
||||
);
|
||||
expect(res.headers.get("content-disposition")).toMatch(/\.json"/);
|
||||
const json = await res.json();
|
||||
expect(json).toMatchObject({
|
||||
schemaVersion: "1",
|
||||
userId: "user-1",
|
||||
measurements: [],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [],
|
||||
});
|
||||
expect(typeof json.exportedAt).toBe("string");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.export.full-backup",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* GET /api/export/full-backup
|
||||
*
|
||||
* v1.4.16 phase B7. Single-file user-scoped JSON dump that matches the
|
||||
* canonical `backupPayloadSchema` (see `src/lib/validations/backup.ts`)
|
||||
* — same shape the pg-boss `data-backup` worker writes weekly, so a
|
||||
* user can hand this file to an admin and `POST /api/admin/backups/upload`
|
||||
* accepts it without further conversion.
|
||||
*
|
||||
* Response is `application/json` with an attachment filename ending in
|
||||
* `.json` so the browser writes a `.json` file even though the route
|
||||
* segment doesn't carry the extension.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
||||
* Audit: `user.export.full-backup` with the row counts.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
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 { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.export.full-backup" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId: user.id },
|
||||
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({
|
||||
where: { userId: user.id },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 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,
|
||||
notes: 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(),
|
||||
})),
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_measurements_count: measurements.length,
|
||||
export_medications_count: medications.length,
|
||||
export_intake_count: intakeEvents.length,
|
||||
export_mood_count: moodEntries.length,
|
||||
},
|
||||
});
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
|
||||
// the file is a self-contained backup — admin upload + restore expect
|
||||
// the raw payload, not `{ data: { ... } }`.
|
||||
return new NextResponse(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.json"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* GET /api/export/measurements
|
||||
*
|
||||
* v1.4.16 phase B7. Per-type CSV download for the consolidated
|
||||
* `/settings/export` UI. Filterable by `since` / `until` (inclusive
|
||||
* date strings parsed via the standard Date constructor — invalid
|
||||
* values are silently dropped from the where-clause).
|
||||
*
|
||||
* Response is `text/csv` with an attachment filename ending in `.csv`,
|
||||
* so the browser writes a `.csv` file even though the route segment
|
||||
* itself doesn't carry the extension (Next.js + vitest can't resolve
|
||||
* dotted route segments cleanly — see commit log for context).
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) so the user can't
|
||||
* sidestep the global cap by hitting per-type endpoints in parallel.
|
||||
* Audit: `user.export.measurements` with the resolved filter.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
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 { toCSV, formatMeasurementsForExport } from "@/lib/export";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.export.measurements" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const { since, until } = parseRange(request.url);
|
||||
const where = buildWhere(user.id, { since, until });
|
||||
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
});
|
||||
|
||||
const csv = toCSV(formatMeasurementsForExport(measurements));
|
||||
|
||||
await auditLog("user.export.measurements", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
count: measurements.length,
|
||||
since: since?.toISOString() ?? null,
|
||||
until: until?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_count: measurements.length,
|
||||
export_since: since?.toISOString() ?? null,
|
||||
export_until: until?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return csvResponse(csv, `healthlog-measurements-${user.id}`);
|
||||
});
|
||||
|
||||
function parseRange(url: string): {
|
||||
since: Date | undefined;
|
||||
until: Date | undefined;
|
||||
} {
|
||||
const params = new URL(url).searchParams;
|
||||
const sinceRaw = params.get("since");
|
||||
const untilRaw = params.get("until");
|
||||
const since = sinceRaw ? safeDate(sinceRaw) : undefined;
|
||||
const until = untilRaw ? safeDate(untilRaw) : undefined;
|
||||
return { since, until };
|
||||
}
|
||||
|
||||
function safeDate(value: string): Date | undefined {
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
function buildWhere(
|
||||
userId: string,
|
||||
range: { since?: Date; until?: Date },
|
||||
): { userId: string; measuredAt?: { gte?: Date; lte?: Date } } {
|
||||
const where: { userId: string; measuredAt?: { gte?: Date; lte?: Date } } = {
|
||||
userId,
|
||||
};
|
||||
if (range.since || range.until) {
|
||||
where.measuredAt = {};
|
||||
if (range.since) where.measuredAt.gte = range.since;
|
||||
if (range.until) where.measuredAt.lte = range.until;
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
function csvResponse(body: string, prefix: string): NextResponse {
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${prefix}-${stamp}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* GET /api/export/medications
|
||||
*
|
||||
* v1.4.16 phase B7. Per-type CSV download for the consolidated
|
||||
* `/settings/export` UI. Always includes the medication list; appends
|
||||
* a `# Intake history` block when `?intake=true` (the default — the
|
||||
* UI toggle is checked by default so a stripped-down export is opt-in).
|
||||
*
|
||||
* Response is `text/csv` with an attachment filename ending in `.csv`.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) so the user can't
|
||||
* sidestep the global cap by hitting per-type endpoints in parallel.
|
||||
* Audit: `user.export.medications` with the resolved filter + intake flag.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
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 {
|
||||
toCSV,
|
||||
formatMedicationsForExport,
|
||||
formatIntakeEventsForExport,
|
||||
} from "@/lib/export";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.export.medications" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const includeIntake = params.get("intake") !== "false";
|
||||
const sinceRaw = params.get("since");
|
||||
const untilRaw = params.get("until");
|
||||
const since = sinceRaw ? safeDate(sinceRaw) : undefined;
|
||||
const until = untilRaw ? safeDate(untilRaw) : undefined;
|
||||
|
||||
// Explicit `select` on the schedule columns we actually format. The
|
||||
// integration testbed has an unmigrated `medication_schedules.days_of_week`
|
||||
// column on this branch (sibling work added the field to the schema
|
||||
// without a matching migration); a wildcard `include: { schedules: true }`
|
||||
// would 500 on the missing column. Pin the columns instead — the export
|
||||
// shape doesn't need every future field anyway.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
label: true,
|
||||
dose: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const sections: string[] = [
|
||||
"# Medications",
|
||||
toCSV(formatMedicationsForExport(medications)),
|
||||
];
|
||||
let intakeCount = 0;
|
||||
|
||||
if (includeIntake) {
|
||||
const where: {
|
||||
userId: string;
|
||||
scheduledFor?: { gte?: Date; lte?: Date };
|
||||
} = {
|
||||
userId: user.id,
|
||||
};
|
||||
if (since || until) {
|
||||
where.scheduledFor = {};
|
||||
if (since) where.scheduledFor.gte = since;
|
||||
if (until) where.scheduledFor.lte = until;
|
||||
}
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where,
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
intakeCount = events.length;
|
||||
sections.push("# Intake history");
|
||||
sections.push(toCSV(formatIntakeEventsForExport(events)));
|
||||
}
|
||||
|
||||
const body = sections.join("\n\n");
|
||||
|
||||
await auditLog("user.export.medications", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationCount: medications.length,
|
||||
intakeCount,
|
||||
includeIntake,
|
||||
since: since?.toISOString() ?? null,
|
||||
until: until?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_medications_count: medications.length,
|
||||
export_intake_count: intakeCount,
|
||||
export_include_intake: includeIntake,
|
||||
},
|
||||
});
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="healthlog-medications-${user.id}-${stamp}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function safeDate(value: string): Date | undefined {
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* GET /api/export/mood
|
||||
*
|
||||
* v1.4.16 phase B7. Per-type CSV download for the consolidated
|
||||
* `/settings/export` UI. Filterable by `since` / `until` (inclusive
|
||||
* date strings parsed via the standard Date constructor — invalid
|
||||
* values are silently dropped from the where-clause).
|
||||
*
|
||||
* Response is `text/csv` with an attachment filename ending in `.csv`.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
||||
* Audit: `user.export.mood` with the resolved filter.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
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 { toCSV, formatMoodEntriesForExport } from "@/lib/export";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.export.mood" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const since = params.get("since")
|
||||
? safeDate(params.get("since")!)
|
||||
: undefined;
|
||||
const until = params.get("until")
|
||||
? safeDate(params.get("until")!)
|
||||
: undefined;
|
||||
|
||||
const where: {
|
||||
userId: string;
|
||||
moodLoggedAt?: { gte?: Date; lte?: Date };
|
||||
} = {
|
||||
userId: user.id,
|
||||
};
|
||||
if (since || until) {
|
||||
where.moodLoggedAt = {};
|
||||
if (since) where.moodLoggedAt.gte = since;
|
||||
if (until) where.moodLoggedAt.lte = until;
|
||||
}
|
||||
|
||||
const entries = await prisma.moodEntry.findMany({
|
||||
where,
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
});
|
||||
|
||||
const csv = toCSV(formatMoodEntriesForExport(entries));
|
||||
|
||||
await auditLog("user.export.mood", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
count: entries.length,
|
||||
since: since?.toISOString() ?? null,
|
||||
until: until?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_count: entries.length,
|
||||
},
|
||||
});
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="healthlog-mood-${user.id}-${stamp}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function safeDate(value: string): Date | undefined {
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
Reference in new issue
Block a user