mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'feat/v139-targets-and-links' into release/v1.39.0
This commit is contained in:
commit
e89f41710a
58 files changed
+2785
-214
No files matched your search
@@ -28,6 +28,14 @@ vi.mock("@/lib/db", () => ({
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
vaccinationDocumentLink: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
vaccinationRecord: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
documentContentIndex: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -73,6 +81,10 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
// Owner by default (every section open); the redaction case narrows it.
|
||||
vi.mock("@/lib/sharing/acting-domains", () => ({
|
||||
actingDomainVisibility: vi.fn(async () => () => true),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -89,10 +101,12 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { GET, PATCH } from "../route";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -141,6 +155,16 @@ beforeEach(() => {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.vaccinationDocumentLink.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.vaccinationDocumentLink.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
@@ -266,4 +290,141 @@ describe("PATCH /api/documents/inbound/[id]", () => {
|
||||
expect(prisma.documentConditionLink.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.documentConditionLink.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replace-sets vaccination links from vaccinationIds and audits the change", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([
|
||||
{ id: "dose-1" },
|
||||
{ id: "dose-2" },
|
||||
] as never);
|
||||
vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue(
|
||||
docRow() as never,
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
mkReq("doc-1", { vaccinationIds: ["dose-1", "dose-2"] }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// The owner-narrowing read is scoped by the session user and the
|
||||
// tombstone, never by anything the body carried.
|
||||
const narrowWhere = vi.mocked(prisma.vaccinationRecord.findMany).mock
|
||||
.calls[0]![0]!.where!;
|
||||
expect(narrowWhere).toMatchObject({ userId: "user-1", deletedAt: null });
|
||||
expect(prisma.vaccinationDocumentLink.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
documentId: "doc-1",
|
||||
vaccinationId: { notIn: ["dose-1", "dose-2"] },
|
||||
},
|
||||
});
|
||||
expect(prisma.vaccinationDocumentLink.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ documentId: "doc-1", vaccinationId: "dose-1", userId: "user-1" },
|
||||
{ documentId: "doc-1", vaccinationId: "dose-2", userId: "user-1" },
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
|
||||
"documents.inbound.update",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ fields: ["vaccinationIds"] }),
|
||||
}),
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.data.vaccinationLinks).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a foreign dose with a 404-shaped response and writes nothing", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const res = await PATCH(
|
||||
mkReq("doc-1", { vaccinationIds: ["dose-foreign"] }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.vaccinationDocumentLink.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.vaccinationDocumentLink.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses more dose links than one request may carry", async () => {
|
||||
const ids = Array.from({ length: 101 }, (_, i) => `dose-${i}`);
|
||||
const res = await PATCH(
|
||||
mkReq("doc-1", { vaccinationIds: ids }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/documents/inbound/[id] — vaccination links", () => {
|
||||
function getReq(id: string): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}`),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
|
||||
docRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue([
|
||||
{ documentId: "doc-1", vaccinationId: "dose-1" },
|
||||
{ documentId: "doc-1", vaccinationId: "dose-2" },
|
||||
] as never);
|
||||
// First read: the link service's target re-read; second: the identity
|
||||
// read that lets the client name each dose.
|
||||
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "dose-1",
|
||||
antigenSlug: "tetanus",
|
||||
vaccineName: null,
|
||||
occurredAt: new Date("1991-04-02T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "dose-2",
|
||||
antigenSlug: "no-longer-in-catalogue",
|
||||
vaccineName: "DTP",
|
||||
occurredAt: new Date("1991-06-02T00:00:00.000Z"),
|
||||
},
|
||||
] as never);
|
||||
});
|
||||
|
||||
it("lists every dose the page is filed against, named for the reader's bundle", async () => {
|
||||
const res = await GET(getReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.vaccinationLinks).toEqual([
|
||||
{
|
||||
vaccinationId: "dose-1",
|
||||
occurredAt: "1991-04-02T00:00:00.000Z",
|
||||
catalogSlug: "tetanus",
|
||||
vaccineName: null,
|
||||
},
|
||||
{
|
||||
vaccinationId: "dose-2",
|
||||
occurredAt: "1991-06-02T00:00:00.000Z",
|
||||
catalogSlug: null,
|
||||
vaccineName: "DTP",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("withholds the doses from a grant that does not cover the health background", async () => {
|
||||
vi.mocked(actingDomainVisibility).mockResolvedValueOnce(
|
||||
(domain) => domain !== "profile",
|
||||
);
|
||||
const res = await GET(getReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.vaccinationLinks).toBeNull();
|
||||
expect(prisma.vaccinationDocumentLink.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,15 @@ import { prisma } from "@/lib/db";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
loadDocumentVaccinationLinks,
|
||||
narrowOwnedEncounterIds,
|
||||
narrowOwnedEpisodeIds,
|
||||
narrowOwnedVaccinationIds,
|
||||
replaceConditionLinks,
|
||||
replaceEncounterLinks,
|
||||
replaceVaccinationLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -81,7 +85,7 @@ async function enforceWriteRateLimit(userId: string): Promise<Response | null> {
|
||||
*/
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read", "documents");
|
||||
const { user, grantId } = await requireRecordAuth("read", "documents");
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -97,18 +101,26 @@ export const GET = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
}),
|
||||
prisma.documentThumbnail.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
// The doses a page records live in the health background, not in the
|
||||
// vault. A grant that opened the vault was never consent for that side
|
||||
// of the seam, so the list is withheld (null) rather than read.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const [links, visitLinks, doseLinks, contentIndex, thumbnail] =
|
||||
await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
visible("profile")
|
||||
? loadDocumentVaccinationLinks(user.id, [document.id])
|
||||
: Promise.resolve(null),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
}),
|
||||
prisma.documentThumbnail.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.get" },
|
||||
@@ -124,6 +136,7 @@ export const GET = apiHandler(
|
||||
toContentIndexSource(contentIndex?.source),
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
doseLinks === null ? null : (doseLinks.get(document.id) ?? []),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -196,6 +209,22 @@ export const PATCH = apiHandler(
|
||||
nextEncounterIds = narrowed;
|
||||
}
|
||||
|
||||
// Replace-set vaccination links, same semantics and same refusal. The
|
||||
// table is the one the dose's own form writes from the other end.
|
||||
let nextVaccinationIds: string[] | undefined;
|
||||
if (parsed.data.vaccinationIds !== undefined) {
|
||||
const narrowed = await narrowOwnedVaccinationIds(
|
||||
user.id,
|
||||
parsed.data.vaccinationIds,
|
||||
);
|
||||
if (narrowed === null) {
|
||||
return apiError("Vaccination not found", 404, {
|
||||
errorCode: "documents.inbound.vaccinationNotFound",
|
||||
});
|
||||
}
|
||||
nextVaccinationIds = narrowed;
|
||||
}
|
||||
|
||||
// No mass assignment — each editable column is set explicitly only when
|
||||
// the client sent it. `userId` is never a body field.
|
||||
const data: Prisma.InboundDocumentUpdateInput = {};
|
||||
@@ -225,28 +254,42 @@ export const PATCH = apiHandler(
|
||||
);
|
||||
}
|
||||
|
||||
if (nextVaccinationIds !== undefined) {
|
||||
await replaceVaccinationLinks(
|
||||
prisma,
|
||||
user.id,
|
||||
existing.id,
|
||||
nextVaccinationIds,
|
||||
);
|
||||
}
|
||||
|
||||
const document = await prisma.inboundDocument.findFirstOrThrow({
|
||||
where: { id: existing.id },
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
}),
|
||||
prisma.documentThumbnail.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
// PATCH is owner-only (bare `requireAuth()`), so every section is the
|
||||
// caller's own and the dose list is always readable here.
|
||||
const [links, visitLinks, doseLinks, contentIndex, thumbnail] =
|
||||
await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
loadDocumentVaccinationLinks(user.id, [document.id]),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
}),
|
||||
prisma.documentThumbnail.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const touched = [
|
||||
...Object.keys(data),
|
||||
...(nextEpisodeIds !== undefined ? ["episodeIds"] : []),
|
||||
...(nextEncounterIds !== undefined ? ["encounterIds"] : []),
|
||||
...(nextVaccinationIds !== undefined ? ["vaccinationIds"] : []),
|
||||
];
|
||||
|
||||
await auditLog("documents.inbound.update", {
|
||||
@@ -272,6 +315,7 @@ export const PATCH = apiHandler(
|
||||
toContentIndexSource(contentIndex?.source),
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
doseLinks.get(document.id) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -49,9 +49,11 @@ import {
|
||||
resolveSeriesFor,
|
||||
} from "@/lib/vaccinations/service";
|
||||
import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching";
|
||||
import { resolveRenewals } from "@/lib/vaccinations/renewal";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const { user, grantId } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = vaccinationListQuerySchema.safeParse({
|
||||
@@ -96,6 +98,31 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
resolveSeriesFor(prisma, user.id),
|
||||
]);
|
||||
|
||||
// v1.39 (#1005) — the renewal state of each antigen, from the booster the
|
||||
// person confirmed. That reminder lives in the measurements section, so a
|
||||
// grant that opened only the health background gets null rather than a
|
||||
// reminder it was never given.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const renewals = visible("measurements")
|
||||
? resolveRenewals(
|
||||
await prisma.measurementReminder.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
vaccinationAntigen: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
vaccinationAntigen: true,
|
||||
nextDueAt: true,
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
new Date(),
|
||||
await resolveOwnerTimezone(user.id),
|
||||
)
|
||||
: null;
|
||||
|
||||
annotate({
|
||||
action: { name: "vaccination.record.list", entity_type: "vaccination" },
|
||||
meta: { count: rows.length, filtered: Boolean(query.antigenSlug) },
|
||||
@@ -105,6 +132,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
vaccinations: rows.map((row) =>
|
||||
toVaccinationDTO(row, series.get(row.id) ?? []),
|
||||
),
|
||||
renewals,
|
||||
};
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user