mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'feat/v144-userneeds' into release/v1.44.0
# Conflicts: # src/__tests__/sharing-surface-guard.test.ts # src/__tests__/workout-canonical-identity-guard.test.ts
This commit is contained in:
commit
aa899a9d54
86 files changed
+12949
-9
No files matched your search
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* `/api/activity/calendar` and `/api/activity/goals` (D1036).
|
||||
*
|
||||
* The goals: a PATCH replaces only what it names, `null` clears, an unknown
|
||||
* key or a goal out of bounds is a 422 and writes nothing, and clearing the
|
||||
* last goal returns the column to NULL rather than storing an empty object.
|
||||
* The calendar: the module gate answers before any read, a future or malformed
|
||||
* day is refused, and the reader is asked for exactly the window the period
|
||||
* needs and never past today.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
readActivityDays: vi.fn(),
|
||||
requireModuleEnabled: vi.fn(),
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
|
||||
requireRecordAuth: vi.fn(async () => ({
|
||||
user: { id: "user-1" },
|
||||
actor: { id: "user-1" },
|
||||
grantId: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: mocks.findUnique, update: mocks.update } },
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: mocks.requireModuleEnabled,
|
||||
}));
|
||||
vi.mock("@/lib/activity/calendar-read", () => ({
|
||||
readActivityDays: mocks.readActivityDays,
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { GET as calendarGet } from "../calendar/route";
|
||||
import { GET as goalsGet, PATCH as goalsPatch } from "../goals/route";
|
||||
|
||||
type Handler = (req: NextRequest) => Promise<Response>;
|
||||
|
||||
function patch(body: unknown) {
|
||||
return (goalsPatch as unknown as Handler)(
|
||||
new NextRequest("http://localhost/api/activity/goals", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function calendar(query: string) {
|
||||
return (calendarGet as unknown as Handler)(
|
||||
new NextRequest(`http://localhost/api/activity/calendar${query}`),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findUnique.mockResolvedValue({
|
||||
activityGoalsJson: { workoutsPerWeek: 3, stepsPerWeek: 50000 },
|
||||
});
|
||||
mocks.update.mockResolvedValue({});
|
||||
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
mocks.readActivityDays.mockResolvedValue({ days: [], loadType: null });
|
||||
});
|
||||
|
||||
describe("activity goals", () => {
|
||||
it("reads every goal, unset ones as null", async () => {
|
||||
const res = await (goalsGet as unknown as () => Promise<Response>)();
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({
|
||||
workoutsPerWeek: 3,
|
||||
activeMinutesPerWeek: null,
|
||||
stepsPerWeek: 50000,
|
||||
stepsPerMonth: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("changes what the body names and keeps the rest", async () => {
|
||||
const res = await patch({ stepsPerWeek: null, stepsPerMonth: 250000 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mocks.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
activityGoalsJson: {
|
||||
workoutsPerWeek: 3,
|
||||
activeMinutesPerWeek: null,
|
||||
stepsPerWeek: null,
|
||||
stepsPerMonth: 250000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mocks.auditLog).toHaveBeenCalledWith(
|
||||
"activity.goals.update",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores NULL once the last goal is cleared", async () => {
|
||||
await patch({ workoutsPerWeek: null, stepsPerWeek: null });
|
||||
expect(mocks.update.mock.calls[0][0].data.activityGoalsJson).toBe(
|
||||
Prisma.DbNull,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ caloriesPerWeek: 3000 }],
|
||||
[{ workoutsPerWeek: 0 }],
|
||||
[{ stepsPerWeek: 2.5 }],
|
||||
[{ stepsPerMonth: "200000" }],
|
||||
])("refuses %j without writing", async (body) => {
|
||||
const res = await patch(body);
|
||||
expect(res.status).toBe(422);
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity calendar", () => {
|
||||
it("answers the module gate before reading anything", async () => {
|
||||
mocks.requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
});
|
||||
const res = await calendar("?period=month");
|
||||
expect(res.status).toBe(403);
|
||||
expect(mocks.readActivityDays).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["?period=year", "?date=2026-13-01", "?date=yesterday"])(
|
||||
"refuses %s",
|
||||
async (q) => {
|
||||
const res = await calendar(q);
|
||||
expect(res.status).toBe(422);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a day after today", async () => {
|
||||
const res = await calendar("?date=2999-01-01");
|
||||
expect(res.status).toBe(422);
|
||||
expect(mocks.readActivityDays).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the baseline window before the period and nothing past the grid", async () => {
|
||||
const res = await calendar("?period=week&date=2025-03-12");
|
||||
expect(res.status).toBe(200);
|
||||
const args = mocks.readActivityDays.mock.calls[0][0];
|
||||
// Week of Monday 2025-03-10; 84 days before it.
|
||||
expect(args.fromKey).toBe("2024-12-16");
|
||||
expect(args.toKey).toBe("2025-03-16");
|
||||
expect(args.tz).toBe("Europe/Berlin");
|
||||
const body = await res.json();
|
||||
expect(body.data.start).toBe("2025-03-10");
|
||||
expect(body.data.goals.workoutsPerWeek).toBe(3);
|
||||
expect(body.data.days).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* `GET /api/activity/calendar` — a week or a month of activity against the
|
||||
* person's own normal, with their weekly and monthly goals (D1036).
|
||||
*
|
||||
* Computed server-side, without AI, from the stored readings: steps (the
|
||||
* canonical-source day totals the steps chart draws), exercise minutes,
|
||||
* workouts after the cross-source collapse, and a training-load score when a
|
||||
* device or HealthLog provides one. What "usual" means is in
|
||||
* `src/lib/activity/calendar-math.ts`; it is always the person's own history,
|
||||
* never a population norm.
|
||||
*
|
||||
* Owned by the Workouts module, like the page that shows it: a switched-off
|
||||
* module answers 403 here as `/api/workouts` does.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
buildActivityCalendar,
|
||||
periodBounds,
|
||||
} from "@/lib/activity/calendar-math";
|
||||
import { readActivityDays } from "@/lib/activity/calendar-read";
|
||||
import { daysBetweenDateKeys, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { activityCalendarQuerySchema } from "@/lib/validations/activity-calendar";
|
||||
import { parseActivityGoals } from "@/lib/validations/activity-goals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** How far back the calendar pages: ten years, the series route's ceiling. */
|
||||
const MAX_DAYS_BACK = 3650 - 200;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
annotate({ action: { name: "activity.calendar.read" } });
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "workouts");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = activityCalendarQuerySchema.safeParse({
|
||||
period: params.get("period") ?? undefined,
|
||||
date: params.get("date") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
const now = new Date();
|
||||
const today = userDayKey(now, tz);
|
||||
const anchor = parsed.data.date ?? today;
|
||||
if (anchor > today) {
|
||||
return apiError("The calendar cannot show a day after today", 422, {
|
||||
errorCode: "activity.calendar.future_date",
|
||||
});
|
||||
}
|
||||
if (daysBetweenDateKeys(anchor, today) > MAX_DAYS_BACK) {
|
||||
return apiError("The calendar reaches back ten years", 422, {
|
||||
errorCode: "activity.calendar.too_old",
|
||||
});
|
||||
}
|
||||
|
||||
const bounds = periodBounds(parsed.data.period, anchor);
|
||||
const lastRead = bounds.gridEnd < today ? bounds.gridEnd : today;
|
||||
const [{ days, loadType }, goalRow] = await Promise.all([
|
||||
readActivityDays({
|
||||
userId: user.id,
|
||||
tz,
|
||||
fromKey: bounds.readStart,
|
||||
toKey: lastRead,
|
||||
now,
|
||||
includeWorkouts: true,
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { activityGoalsJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const calendar = buildActivityCalendar({
|
||||
bounds,
|
||||
today,
|
||||
input: days,
|
||||
goals: parseActivityGoals(goalRow?.activityGoalsJson ?? null),
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
activity_calendar_period: bounds.period,
|
||||
activity_calendar_days_read: days.length,
|
||||
activity_calendar_load_type: loadType ?? "none",
|
||||
},
|
||||
});
|
||||
return apiSuccess({ ...calendar, timezone: tz, loadType });
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* GET / PATCH the caller's activity goals (D1036).
|
||||
*
|
||||
* Every goal is optional and set by the person; there is no default goal.
|
||||
* PATCH replaces the goals the body names (`null` clears one) and keeps the
|
||||
* rest; an unknown key or an out-of-bounds goal is refused with the
|
||||
* multi-issue 422. The goals live on their own `User` column
|
||||
* (`activity_goals_json`) and the activity calendar reads them.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
ACTIVITY_GOAL_KEYS,
|
||||
activityGoalsSchema,
|
||||
hasNoActivityGoals,
|
||||
mergeActivityGoals,
|
||||
parseActivityGoals,
|
||||
type ActivityGoals,
|
||||
} from "@/lib/validations/activity-goals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function readGoals(userId: string): Promise<ActivityGoals> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { activityGoalsJson: true },
|
||||
});
|
||||
return parseActivityGoals(row?.activityGoalsJson ?? null);
|
||||
}
|
||||
|
||||
function setKeys(goals: ActivityGoals): string[] {
|
||||
return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
// Owner-only, like the write: a delegate sees the goals where they matter,
|
||||
// inside the calendar, which carries them.
|
||||
const { user } = await requireAuth();
|
||||
const goals = await readGoals(user.id);
|
||||
annotate({
|
||||
action: { name: "activity.goals.read" },
|
||||
meta: { set: setKeys(goals) },
|
||||
});
|
||||
return apiSuccess(goals);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = activityGoalsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({ action: { name: "activity.goals.validation-failed" } });
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const next = mergeActivityGoals(await readGoals(user.id), parsed.data);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
activityGoalsJson: hasNoActivityGoals(next)
|
||||
? Prisma.DbNull
|
||||
: toJson(next),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("activity.goals.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { set: setKeys(next) },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "activity.goals.update" },
|
||||
meta: { set: setKeys(next) },
|
||||
});
|
||||
return apiSuccess(next);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The request side of `/api/import/labs-csv` and `/api/import/medications-csv`
|
||||
* (#1215): module gate first, a separate bucket for dry runs, the content
|
||||
* type, the byte cap while streaming, the mapping refused before anything is
|
||||
* read, and an audit row only for a real import.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
requireModuleEnabled: vi.fn(),
|
||||
importLabsCsv: vi.fn(),
|
||||
importMedicationsCsv: vi.fn(),
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: mocks.requireModuleEnabled,
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
vi.mock("@/lib/import/labs-csv", () => ({
|
||||
importLabsCsv: mocks.importLabsCsv,
|
||||
}));
|
||||
vi.mock("@/lib/import/medications-csv", () => ({
|
||||
importMedicationsCsv: mocks.importMedicationsCsv,
|
||||
}));
|
||||
|
||||
import { CsvTooLargeError } from "@/lib/import/csv-stream";
|
||||
import { POST as labsPost } from "../labs-csv/route";
|
||||
import { POST as medsPost } from "../medications-csv/route";
|
||||
|
||||
type Handler = (req: NextRequest) => Promise<Response>;
|
||||
const RESULT = {
|
||||
kind: "labs",
|
||||
dryRun: false,
|
||||
rows: 2,
|
||||
valid: 2,
|
||||
inserted: 2,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
errorsTruncated: false,
|
||||
preview: [],
|
||||
};
|
||||
|
||||
function post(
|
||||
handler: unknown,
|
||||
query: string,
|
||||
body = "a,b\n1,2\n",
|
||||
type = "text/csv",
|
||||
) {
|
||||
return (handler as Handler)(
|
||||
new NextRequest(`http://localhost/api/import/x?${query}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": type },
|
||||
body,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
mocks.importLabsCsv.mockResolvedValue(RESULT);
|
||||
mocks.importMedicationsCsv.mockResolvedValue({
|
||||
...RESULT,
|
||||
kind: "medications",
|
||||
});
|
||||
});
|
||||
|
||||
const LABS = "analyte=0&value=1&unit=2&date=3";
|
||||
const MEDS = "name=0&dose=1×=2";
|
||||
|
||||
describe("record CSV import routes", () => {
|
||||
it("answers the module gate before reading the file", async () => {
|
||||
mocks.requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
});
|
||||
expect((await post(labsPost, LABS)).status).toBe(403);
|
||||
expect(mocks.requireModuleEnabled).toHaveBeenCalledWith("user-1", "labs");
|
||||
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a mapping without the required columns, or with a shared column", async () => {
|
||||
expect((await post(labsPost, "analyte=0&value=1")).status).toBe(422);
|
||||
expect((await post(labsPost, "analyte=0&value=0&date=1")).status).toBe(422);
|
||||
expect((await post(medsPost, "name=0")).status).toBe(422);
|
||||
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("spends the preview bucket on a dry run and the import bucket otherwise", async () => {
|
||||
await post(labsPost, `${LABS}&dryRun=1`);
|
||||
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
|
||||
"import-preview:user-1",
|
||||
30,
|
||||
3_600_000,
|
||||
);
|
||||
expect(mocks.auditLog).not.toHaveBeenCalled();
|
||||
await post(medsPost, MEDS);
|
||||
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
|
||||
"import:user-1",
|
||||
5,
|
||||
3_600_000,
|
||||
);
|
||||
expect(mocks.auditLog).toHaveBeenCalledWith(
|
||||
"import.medications.csv",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("answers 429 when the bucket is spent", async () => {
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: false });
|
||||
expect((await post(labsPost, LABS)).status).toBe(429);
|
||||
});
|
||||
|
||||
it("refuses a body that is not CSV", async () => {
|
||||
expect((await post(labsPost, LABS, "{}", "application/json")).status).toBe(
|
||||
415,
|
||||
);
|
||||
});
|
||||
|
||||
it("turns the streaming cap into a 413", async () => {
|
||||
mocks.importLabsCsv.mockRejectedValue(new CsvTooLargeError(1));
|
||||
expect((await post(labsPost, LABS)).status).toBe(413);
|
||||
});
|
||||
|
||||
it("calls a file without data rows empty", async () => {
|
||||
mocks.importLabsCsv.mockResolvedValue({ ...RESULT, rows: 0 });
|
||||
expect((await post(labsPost, LABS)).status).toBe(422);
|
||||
});
|
||||
|
||||
it("hands the parsed mapping and the account zone to the importer", async () => {
|
||||
const res = await post(
|
||||
labsPost,
|
||||
`${LABS}&decimal=comma&dateOrder=dmy&delimiter=semicolon`,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const args = mocks.importLabsCsv.mock.calls[0][0];
|
||||
expect(args.tz).toBe("Europe/Berlin");
|
||||
expect(args.query).toMatchObject({
|
||||
analyte: 0,
|
||||
value: 1,
|
||||
unit: 2,
|
||||
date: 3,
|
||||
decimal: "comma",
|
||||
dateOrder: "dmy",
|
||||
delimiter: "semicolon",
|
||||
dryRun: false,
|
||||
header: true,
|
||||
});
|
||||
expect(args.stream).toBeInstanceOf(ReadableStream);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* `POST /api/import/labs-csv` — lab results from a spreadsheet (#1215).
|
||||
*
|
||||
* Body: the CSV file, streamed. Query: the column mapping and the file's
|
||||
* conventions (`labsCsvQuerySchema`). `dryRun=1` checks every row and reports
|
||||
* what would happen without writing; the real import writes in chunks, skips
|
||||
* readings the record already holds and reports every refused row by line.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { importLabsCsv } from "@/lib/import/labs-csv";
|
||||
import { handleRecordImport } from "@/lib/import/record-import-route";
|
||||
import { labsCsvQuerySchema } from "@/lib/validations/record-import";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) =>
|
||||
handleRecordImport(request, {
|
||||
kind: "labs",
|
||||
module: "labs",
|
||||
schema: labsCsvQuerySchema,
|
||||
run: (args) => importLabsCsv(args),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* `POST /api/import/medications-csv` — medications from a spreadsheet (#1215).
|
||||
*
|
||||
* Body: the CSV file, streamed. Query: the column mapping and the file's
|
||||
* conventions (`medicationsCsvQuerySchema`). Maps only name, dose, daily
|
||||
* times, as-needed and a start and end date; reminders stay off unless the
|
||||
* query turns them on. `dryRun=1` checks without writing.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { importMedicationsCsv } from "@/lib/import/medications-csv";
|
||||
import { handleRecordImport } from "@/lib/import/record-import-route";
|
||||
import { medicationsCsvQuerySchema } from "@/lib/validations/record-import";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) =>
|
||||
handleRecordImport(request, {
|
||||
kind: "medications",
|
||||
module: "medications",
|
||||
schema: medicationsCsvQuerySchema,
|
||||
run: (args) => importMedicationsCsv(args),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* `GET /api/measurements/provenance/compare` — two sources of one metric side
|
||||
* by side over up to a year of UTC days, with the source the ladder picked
|
||||
* each day (#1204). Read-only; one aggregate query, at most one row per day
|
||||
* and source.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readSourceComparison } from "@/lib/provenance/source-provenance";
|
||||
import { sourceComparisonQuerySchema } from "@/lib/validations/source-provenance";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
annotate({ action: { name: "measurement.provenance.compare" } });
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = sourceComparisonQuerySchema.safeParse({
|
||||
type: params.get("type") ?? undefined,
|
||||
from: params.get("from") ?? undefined,
|
||||
to: params.get("to") ?? undefined,
|
||||
a: params.get("a") ?? undefined,
|
||||
b: params.get("b") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const result = await readSourceComparison({
|
||||
userId: user.id,
|
||||
...parsed.data,
|
||||
});
|
||||
annotate({
|
||||
meta: {
|
||||
provenance_type: result.type,
|
||||
provenance_days: result.days.length,
|
||||
provenance_sources: result.sources.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* `GET /api/measurements/provenance` — which sources delivered a metric on a
|
||||
* day, which one the source-priority ladder picked, and why (#1204, #1108,
|
||||
* #576). Read-only; the explanation comes from the same rank SQL the charts
|
||||
* and summaries pick with (`src/lib/provenance/source-provenance.ts`).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readDayProvenance } from "@/lib/provenance/source-provenance";
|
||||
import { dayProvenanceQuerySchema } from "@/lib/validations/source-provenance";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
annotate({ action: { name: "measurement.provenance.day" } });
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = dayProvenanceQuerySchema.safeParse({
|
||||
type: params.get("type") ?? undefined,
|
||||
date: params.get("date") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const result = await readDayProvenance({
|
||||
userId: user.id,
|
||||
type: parsed.data.type,
|
||||
date: parsed.data.date,
|
||||
});
|
||||
annotate({
|
||||
meta: {
|
||||
provenance_type: result.type,
|
||||
provenance_sources: result.sources.length,
|
||||
provenance_pick_reason: result.pickReason ?? "none",
|
||||
},
|
||||
});
|
||||
return apiSuccess(result);
|
||||
});
|
||||
Reference in new issue
Block a user