fix: retain import actor attribution

Persist record and actor identities through job completion.\nWrite one bounded transactional completion audit per job.
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 02:00:35 +02:00
1 parent adb932052c
commit 620817b10b
19 files changed
+211 -63

No files matched your search

@@ -61,7 +61,11 @@ describe("GET medication intake import status", () => {
expect(response.status).toBe(404);
expect(prisma.medicationIntakeImportJob.findFirst).toHaveBeenCalledWith({
where: { id: "job-1", medicationId: "med-1", userId: "user-1" },
where: {
id: "job-1",
medicationId: "med-1",
recordUserId: "user-1",
},
});
});
@@ -1,6 +1,6 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readMedicationIntakeImportJob } from "@/lib/medications/intake-import-job-status";
@@ -13,7 +13,7 @@ export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteContext) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("manage", "medications");
const { id: medicationId, jobId } = await params;
annotate({
action: { name: "medication.intake.import.status" },
@@ -237,7 +237,8 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
expect(prisma.medicationIntakeImportJob.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
recordUserId: "user-1",
actorUserId: "user-1",
medicationId: "m1",
status: "queued",
// A legacy `zaehler` on the first row rides along and decides nothing:
@@ -303,7 +304,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
expect(prisma.medicationIntakeImportJob.updateMany).toHaveBeenCalledWith({
where: expect.objectContaining({
medicationId: "m1",
userId: "user-1",
recordUserId: "user-1",
status: { in: ["queued", "running"] },
OR: expect.arrayContaining([
{
@@ -187,7 +187,8 @@ export const POST = apiHandler(
rollupProcessed: 0,
};
const admission = await admitIntakeImportJob({
userId: user.id,
recordUserId: user.id,
actorUserId: actor.id,
medicationId: id,
payload: normalized,
progress,
@@ -1,6 +1,6 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readMedicationIntakeImportJob } from "@/lib/medications/intake-import-job-status";
@@ -13,7 +13,7 @@ export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteContext) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("manage", "medications");
const { jobId } = await params;
annotate({
action: { name: "medication.intake.history.import.status" },
@@ -148,7 +148,8 @@ describe("POST /api/medications/intake/dose-history-import", () => {
await POST(postReq(CSV));
expect(prisma.medicationIntakeImportJob.create).toHaveBeenCalledWith({
data: {
userId: "user-1",
recordUserId: "user-1",
actorUserId: "user-1",
// No medication owns a file covering a regimen; each entry names its own.
medicationId: null,
status: "queued",
@@ -258,7 +259,7 @@ describe("POST /api/medications/intake/dose-history-import", () => {
// reach across and fail it.
expect(prisma.medicationIntakeImportJob.findFirst).toHaveBeenCalledWith({
where: {
userId: "user-1",
recordUserId: "user-1",
medicationId: null,
status: { in: ["queued", "running"] },
},
@@ -267,7 +268,7 @@ describe("POST /api/medications/intake/dose-history-import", () => {
expect(prisma.medicationIntakeImportJob.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
recordUserId: "user-1",
medicationId: null,
}),
}),
@@ -197,7 +197,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
};
const admission = await admitIntakeImportJob({
userId: user.id,
recordUserId: user.id,
actorUserId: actor.id,
medicationId: null,
payload,
progress,
+10 -2
View File
@@ -49,16 +49,24 @@ export async function auditLog(
action: string,
opts: {
userId?: string | null;
/** Explicit worker attribution when no request context remains. */
actorUserId?: string | null;
/** Use the active transaction when the audit must commit atomically. */
client?: Pick<typeof prisma, "auditLog">;
details?: Record<string, unknown>;
ipAddress?: string | null;
} = {},
) {
const userId = opts.userId ?? null;
const entry = await prisma.auditLog.create({
const client = opts.client ?? prisma;
const entry = await client.auditLog.create({
data: {
action,
userId,
actorUserId: actingActorFor(userId),
actorUserId:
opts.actorUserId === undefined
? actingActorFor(userId)
: opts.actorUserId,
details: opts.details ? JSON.stringify(opts.details) : null,
ipAddress: opts.ipAddress ?? null,
},
@@ -125,13 +125,14 @@ describe("medication intake import worker bounds", () => {
medicationIntakeImportJob: {
findUnique: async () => ({
id: "job-many-days",
userId: "user-1",
recordUserId: "user-1",
actorUserId: "user-1",
medicationId: "medication-1",
status,
payload: { entries: [] },
progress: persistedProgress,
startedAt: new Date(),
user: { timezone: "UTC" },
recordUser: { timezone: "UTC" },
}),
update: async (args: {
data: { progress?: unknown; status?: string };
+22 -22
View File
@@ -2,6 +2,7 @@ import type { Job, JobWithMetadata } from "pg-boss";
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
import { consumeImportedIntakesBatch } from "@/lib/medications/inventory/consumption";
@@ -584,14 +585,14 @@ async function processNextChunk(
const row = await tx.medicationIntakeImportJob.findUnique({
where: { id: jobId },
include: { user: { select: { timezone: true } } },
include: { recordUser: { select: { timezone: true } } },
});
if (!row || row.status === "done" || row.status === "failed") {
return {
terminal: true,
finalized: false,
result: null,
userId: row?.userId ?? null,
userId: row?.recordUserId ?? null,
};
}
@@ -604,9 +605,9 @@ async function processNextChunk(
);
await recomputeTouchedDays(
tx,
row.userId,
row.recordUserId,
row.medicationId,
row.user.timezone,
row.recordUser.timezone,
rollupDays,
);
const finalizedProgress: MedicationImportProgress = {
@@ -629,7 +630,7 @@ async function processNextChunk(
terminal: false,
finalized: false,
result: null,
userId: row.userId,
userId: row.recordUserId,
};
}
const result: MedicationImportResult = {
@@ -644,18 +645,17 @@ async function processNextChunk(
: { skippedDetailsOmitted: progress.skippedDetailsOmitted }),
};
const completedAt = new Date();
await tx.auditLog.create({
data: {
userId: row.userId,
action: "medication.intake.import",
details: JSON.stringify({
jobId: row.id,
medicationId: row.medicationId,
imported: result.imported,
skipped: result.skipped,
skipReasons: result.skipReasons,
total: progress.total,
}),
await auditLog("medication.intake.import", {
client: tx,
userId: row.recordUserId,
actorUserId: row.actorUserId,
details: {
jobId: row.id,
recordUserId: row.recordUserId,
actorUserId: row.actorUserId,
imported: result.imported,
skipped: result.skipped,
total: progress.total,
},
});
await tx.medicationIntakeImportJob.update({
@@ -673,7 +673,7 @@ async function processNextChunk(
terminal: true,
finalized: true,
result,
userId: row.userId,
userId: row.recordUserId,
};
}
@@ -714,7 +714,7 @@ async function processNextChunk(
const takenAt =
entry.takenAt === null ? null : new Date(entry.takenAt);
return {
userId: row.userId,
userId: row.recordUserId,
medicationId: resolveMedicationId(entry),
// The scheduled slot and the time taken are two separate facts, and
// the export states both. Writing the take into both columns — which
@@ -774,7 +774,7 @@ async function processNextChunk(
for (const [medicationId, events] of consumedByMedication) {
await consumeImportedIntakesBatch({
client: tx,
userId: row.userId,
userId: row.recordUserId,
medicationId,
events,
});
@@ -783,7 +783,7 @@ async function processNextChunk(
const touchedDays = created.map((event) =>
encodeTouchedDay(
event.medicationId,
dayKeyForScheduledFor(event.scheduledFor, row.user.timezone),
dayKeyForScheduledFor(event.scheduledFor, row.recordUser.timezone),
),
);
// Two different facts, counted apart. The Map collapse above is entries
@@ -818,7 +818,7 @@ async function processNextChunk(
terminal: false,
finalized: false,
result: null,
userId: row.userId,
userId: row.recordUserId,
};
},
{ timeout: 60_000 },
+12 -8
View File
@@ -33,7 +33,8 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
export interface IntakeImportAdmissionInput {
userId: string;
recordUserId: string;
actorUserId: string;
/** `null` = the account-wide dose-history import. */
medicationId: string | null;
payload: MedicationImportPayload;
@@ -60,7 +61,8 @@ export type IntakeImportAdmission =
* submissions serialise against each other without blocking an unrelated one.
*/
export async function admitIntakeImportJob({
userId,
recordUserId,
actorUserId,
medicationId,
payload,
progress,
@@ -71,7 +73,7 @@ export async function admitIntakeImportJob({
await tx.$queryRaw`
SELECT "id"
FROM "users"
WHERE "id" = ${userId}
WHERE "id" = ${recordUserId}
FOR UPDATE
`;
} else {
@@ -79,7 +81,7 @@ export async function admitIntakeImportJob({
SELECT "id"
FROM "medications"
WHERE "id" = ${medicationId}
AND "user_id" = ${userId}
AND "user_id" = ${recordUserId}
FOR UPDATE
`;
}
@@ -94,7 +96,7 @@ export async function admitIntakeImportJob({
// heartbeat, after the start, and before either.
await tx.medicationIntakeImportJob.updateMany({
where: {
userId,
recordUserId,
medicationId,
status: { in: ["queued", "running"] },
OR: [
@@ -117,7 +119,7 @@ export async function admitIntakeImportJob({
const activeJob = await tx.medicationIntakeImportJob.findFirst({
where: {
userId,
recordUserId,
medicationId,
status: { in: ["queued", "running"] },
},
@@ -127,7 +129,8 @@ export async function admitIntakeImportJob({
const importJob = await tx.medicationIntakeImportJob.create({
data: {
userId,
recordUserId,
actorUserId,
medicationId,
status: "queued",
payload: toJson(payload),
@@ -169,7 +172,8 @@ export async function admitIntakeImportJob({
},
});
await auditLog("medication.intake.import.kickoff.denied", {
userId,
userId: recordUserId,
actorUserId,
ipAddress,
details: {
jobId,
@@ -109,18 +109,18 @@ export function projectMedicationImportResult(
* Read one job the caller owns, or `null`.
*
* `medicationId` narrows to a job scoped to that medication; omit it for the
* account-wide import. `userId` is always in the `where`, so a job id belonging
* account-wide import. `recordUserId` is always in the `where`, so a job id belonging
* to somebody else is a 404 and not a leak.
*/
export async function readMedicationIntakeImportJob(
userId: string,
recordUserId: string,
jobId: string,
medicationId?: string,
): Promise<MedicationIntakeImportJobStatus | null> {
const row = await prisma.medicationIntakeImportJob.findFirst({
where: {
id: jobId,
userId,
recordUserId,
// An account-wide job carries no medication, and asking for one by id must
// not resolve to the other kind. `null` is the explicit narrowing.
medicationId: medicationId ?? null,