mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(illness): add date-less day-log list and full-history timeline
The episode day-logs endpoint only accepted a single `date`, so the
detail timeline could anchor on today alone. Add a date-less LIST variant
to GET /api/illness/episodes/{id}/day-logs: when no `date` is given,
return the episode's day-logs newest-first with limit/offset/sortDir
paging and meta.total (mirrors the Labs list envelope). The single-date
read stays intact, selected by the param's presence.
Wire the detail timeline onto the list for a full historical scroll
instead of today only, keeping the neutral palette and retrospective
copy. Register the list response in the OpenAPI contract for the iOS
client.
This commit is contained in:
1 parent
b5c1d1fc4e
commit
cdf43c3669
16 files changed
+482
-104
No files matched your search
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* v1.18.3 — GET /api/illness/episodes/{id}/day-logs.
|
||||
*
|
||||
* Covers the date-less LIST mode (paged, newest-first, `meta.total`), the
|
||||
* cursor/offset paging params, and that the legacy single-`date` read still
|
||||
* resolves one row (back-compat). The episode is owned + live and the module
|
||||
* gate is enabled in every case below.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
illnessEpisode: { findUnique: vi.fn() },
|
||||
illnessDayLog: { findMany: vi.fn(), findFirst: vi.fn(), count: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/illness/gate", () => ({
|
||||
requireIllnessEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const PARAMS = { params: Promise.resolve({ id: "ep-1" }) };
|
||||
|
||||
function makeRow(date: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: `dl-${date}`,
|
||||
episodeId: "ep-1",
|
||||
date,
|
||||
functionalImpact: 1,
|
||||
feverC: null,
|
||||
noteEncrypted: null,
|
||||
updatedAt: new Date("2026-06-15T00:00:00.000Z"),
|
||||
symptomLinks: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function getReq(qs: string): NextRequest {
|
||||
const suffix = qs ? `?${qs}` : "";
|
||||
return new NextRequest(
|
||||
`http://localhost/api/illness/episodes/ep-1/day-logs${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireIllnessEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue({
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/illness/episodes/{id}/day-logs — date-less list", () => {
|
||||
it("returns paged day-logs newest-first with meta.total", async () => {
|
||||
vi.mocked(prisma.illnessDayLog.findMany).mockResolvedValue([
|
||||
makeRow("2026-06-15"),
|
||||
makeRow("2026-06-14"),
|
||||
] as never);
|
||||
vi.mocked(prisma.illnessDayLog.count).mockResolvedValue(5 as never);
|
||||
|
||||
const res = await GET(getReq(""), PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
dayLogs: Array<{ id: string; date: string }>;
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
};
|
||||
};
|
||||
expect(body.data.dayLogs).toHaveLength(2);
|
||||
expect(body.data.dayLogs[0].date).toBe("2026-06-15");
|
||||
expect(body.data.meta).toEqual({ total: 5, limit: 60, offset: 0 });
|
||||
|
||||
// Default sort is newest-first; the list query never carries a `date`.
|
||||
const findArgs = vi.mocked(prisma.illnessDayLog.findMany).mock.calls[0][0];
|
||||
expect(findArgs).toMatchObject({
|
||||
where: { episodeId: "ep-1", deletedAt: null },
|
||||
orderBy: { date: "desc" },
|
||||
take: 60,
|
||||
skip: 0,
|
||||
});
|
||||
expect(vi.mocked(prisma.illnessDayLog.findFirst)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours limit/offset/sortDir paging params", async () => {
|
||||
vi.mocked(prisma.illnessDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.illnessDayLog.count).mockResolvedValue(0 as never);
|
||||
|
||||
const res = await GET(getReq("limit=10&offset=20&sortDir=asc"), PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { meta: { total: number; limit: number; offset: number } };
|
||||
};
|
||||
expect(body.data.meta).toEqual({ total: 0, limit: 10, offset: 20 });
|
||||
|
||||
const findArgs = vi.mocked(prisma.illnessDayLog.findMany).mock.calls[0][0];
|
||||
expect(findArgs).toMatchObject({
|
||||
orderBy: { date: "asc" },
|
||||
take: 10,
|
||||
skip: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("422s on an out-of-range limit", async () => {
|
||||
const res = await GET(getReq("limit=9999"), PARAMS);
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.illnessDayLog.findMany)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/illness/episodes/{id}/day-logs — single-day (back-compat)", () => {
|
||||
it("returns one day-log for an explicit date", async () => {
|
||||
vi.mocked(prisma.illnessDayLog.findFirst).mockResolvedValue(
|
||||
makeRow("2026-06-10") as never,
|
||||
);
|
||||
|
||||
const res = await GET(getReq("date=2026-06-10"), PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { id: string; date: string } | null;
|
||||
};
|
||||
expect(body.data?.date).toBe("2026-06-10");
|
||||
|
||||
const findArgs = vi.mocked(prisma.illnessDayLog.findFirst).mock.calls[0][0];
|
||||
expect(findArgs).toMatchObject({
|
||||
where: { episodeId: "ep-1", date: "2026-06-10", deletedAt: null },
|
||||
});
|
||||
expect(vi.mocked(prisma.illnessDayLog.findMany)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when nothing is logged that day", async () => {
|
||||
vi.mocked(prisma.illnessDayLog.findFirst).mockResolvedValue(null as never);
|
||||
|
||||
const res = await GET(getReq("date=2026-06-09"), PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: unknown };
|
||||
expect(body.data).toBeNull();
|
||||
});
|
||||
|
||||
it("422s on a malformed date", async () => {
|
||||
const res = await GET(getReq("date=06-2026"), PARAMS);
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.illnessDayLog.findFirst)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@
|
||||
* `GET /api/illness/episodes/{id}/day-logs?date=YYYY-MM-DD` — the one
|
||||
* day-log for the episode + day, or `null` when nothing is logged
|
||||
* (lets the log-day sheet pre-fill).
|
||||
* `GET /api/illness/episodes/{id}/day-logs` (no `date`) — the date-less
|
||||
* LIST: the episode's day-logs newest-first, paginated
|
||||
* (`limit`/`offset`/`sortDir` + `meta.total`). Powers the detail
|
||||
* timeline's full historical scroll + iOS (healthlog-iOS#30).
|
||||
* `POST /api/illness/episodes/{id}/day-logs` — upsert the day's symptom /
|
||||
* functional-impact / fever timeline row. Upserts on
|
||||
* `(episodeId, date)`: 201 on insert, 200 on update.
|
||||
@@ -25,6 +29,7 @@ import {
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
import {
|
||||
illnessDayLogInputSchema,
|
||||
illnessDayLogListQuerySchema,
|
||||
illnessDayLogQuerySchema,
|
||||
} from "@/lib/validations/illness";
|
||||
import { upsertIllnessDayLog } from "@/lib/illness/day-log-write";
|
||||
@@ -59,8 +64,54 @@ export const GET = apiHandler(
|
||||
const owned = await loadOwnedEpisode(id, user.id);
|
||||
if (!owned.ok) return owned.response;
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
// No `date` → the date-less LIST: the episode's day-logs newest-first,
|
||||
// paginated, with `meta.total`. A `date` keeps the single-day read intact
|
||||
// (back-compat). The two modes are mutually exclusive by the param's
|
||||
// presence, so a list caller never trips the single-day Zod gate.
|
||||
if (!searchParams.has("date")) {
|
||||
const parsedList = illnessDayLogListQuerySchema.safeParse({
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
offset: searchParams.get("offset") ?? undefined,
|
||||
sortDir: searchParams.get("sortDir") ?? undefined,
|
||||
});
|
||||
if (!parsedList.success) {
|
||||
return returnAllZodIssues(parsedList.error, 422, {
|
||||
errorCode: "illness.day-log.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { limit, offset, sortDir } = parsedList.data;
|
||||
const where = { episodeId: id, deletedAt: null };
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.illnessDayLog.findMany({
|
||||
where,
|
||||
orderBy: { date: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: dayLogSymptomInclude,
|
||||
}),
|
||||
prisma.illnessDayLog.count({ where }),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.day-log.list",
|
||||
entity_type: "illness_day_log",
|
||||
},
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
dayLogs: rows.map(toIllnessDayLogDTO),
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = illnessDayLogQuerySchema.safeParse({
|
||||
date: new URL(request.url).searchParams.get("date"),
|
||||
date: searchParams.get("date"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
|
||||
Reference in new issue
Block a user