mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(meds): reconcile one-shot active flag on every intake mutation
Extracts the lifecycle hook into reconcileOneShotState and wires it into POST, PUT, and DELETE on the intake event. A user who logs the single dose then undoes the log gets the medication back; a user who flips skipped does too. The hook is idempotent on non-one-shot medications. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c7e839f97a
commit
1cc6f500bb
6 files changed
+425
-31
No files matched your search
@@ -11,6 +11,11 @@ vi.mock("@/lib/db", () => ({
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
medication: {
|
||||
findUnique: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
@@ -39,9 +44,10 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { PUT, DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -70,6 +76,18 @@ beforeEach(() => {
|
||||
medicationId: "m1",
|
||||
scheduledFor: new Date(),
|
||||
} as never);
|
||||
// Default lifecycle stubs: medication is NOT one-shot so the
|
||||
// reconcile is a no-op for the legacy 422-envelope tests below.
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
oneShot: false,
|
||||
active: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
@@ -139,3 +157,81 @@ describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.4
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.5.0 — one-shot lifecycle reconciliation on PUT / DELETE
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function deleteReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/m1/intake/e1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
describe("DELETE /api/medications/[id]/intake/[eventId] — one-shot reconcile", () => {
|
||||
it("re-activates a one-shot medication after the deleted event was its last live intake", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValueOnce({
|
||||
oneShot: true,
|
||||
active: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.updateMany).mockResolvedValueOnce({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.delete).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const res = await DELETE(deleteReq(), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medication.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "m1", userId: "user-1", oneShot: true },
|
||||
data: { active: true },
|
||||
});
|
||||
const calls = vi.mocked(auditLog).mock.calls.map((c) => c[0]);
|
||||
expect(calls).toContain("medication.oneShot.reconciled");
|
||||
});
|
||||
|
||||
it("is idempotent on a non-one-shot medication", async () => {
|
||||
// Default beforeEach: medication is non-one-shot.
|
||||
vi.mocked(prisma.medicationIntakeEvent.delete).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
const res = await DELETE(deleteReq(), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medication.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id]/intake/[eventId] — one-shot reconcile on skip flip", () => {
|
||||
it("re-activates a one-shot medication when its single intake is flipped to skipped", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValueOnce({
|
||||
oneShot: true,
|
||||
active: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.updateMany).mockResolvedValueOnce({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
skipped: true,
|
||||
takenAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(putReq({ skipped: true, takenAt: null }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medication.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "m1", userId: "user-1", oneShot: true },
|
||||
data: { active: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { updateIntakeEventSchema } from "@/lib/validations/medication";
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -119,6 +120,12 @@ export const PUT = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
// v1.5.0 — re-evaluate the one-shot active flag. A skip-flip on the
|
||||
// single live intake of a one-shot medication should reactivate it
|
||||
// (the dose is no longer logged), and the reverse case should
|
||||
// deactivate again. No-op for non-one-shot medications.
|
||||
await reconcileOneShotState(prisma, id, user.id);
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
@@ -169,6 +176,12 @@ export const DELETE = apiHandler(
|
||||
tz: user.timezone,
|
||||
});
|
||||
|
||||
// v1.5.0 — re-evaluate the one-shot active flag. Deleting the
|
||||
// single live intake of a one-shot medication reactivates it so
|
||||
// the dashboard / lists / worker pick it back up. No-op for
|
||||
// non-one-shot medications.
|
||||
await reconcileOneShotState(prisma, id, user.id);
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -24,7 +24,11 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medication: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
@@ -266,3 +270,56 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.5.0 — POST intake on a one-shot medication still deactivates
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => {
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("flips active to false after logging a live intake on a one-shot medication", async () => {
|
||||
const createdEvent = {
|
||||
id: "evt-1",
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: new Date(),
|
||||
takenAt: new Date(),
|
||||
skipped: false,
|
||||
};
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([createdEvent] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
|
||||
null as never, // dedup probe
|
||||
);
|
||||
// Reconcile probes — first call is the medication shape, second is
|
||||
// the live-intake probe (a live intake exists because the POST just
|
||||
// created one), and the update fires with active:false.
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValueOnce({
|
||||
oneShot: true,
|
||||
active: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({
|
||||
id: "evt-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.updateMany).mockResolvedValueOnce({
|
||||
count: 1,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({ takenAt: new Date().toISOString(), skipped: false }),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
expect(prisma.medication.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "med-1", userId: "user-1", oneShot: true },
|
||||
data: { active: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/lib/validations/medication";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { consumeOneDose } from "@/lib/medications/inventory/service";
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
@@ -197,35 +198,15 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
tz: user.timezone,
|
||||
});
|
||||
|
||||
// v1.5.0 — one-shot lifecycle. A `oneShot` medication has at most
|
||||
// one intake; once that intake is logged (non-skipped) the
|
||||
// medication auto-deactivates so the reminder worker stops
|
||||
// considering it and the dashboard "Erfassen" sheet drops it. The
|
||||
// flip runs AFTER the intake row is committed (any failure inside
|
||||
// the transaction above re-raises before this line), so a flaky
|
||||
// write never deactivates a medication that didn't actually
|
||||
// receive its dose. Skipped intakes don't qualify — a "skipped"
|
||||
// event on a one-shot leaves the medication open for the eventual
|
||||
// real dose.
|
||||
const medForOneShotCheck = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { oneShot: true, active: true },
|
||||
});
|
||||
if (medForOneShotCheck?.oneShot && medForOneShotCheck.active && !skipped) {
|
||||
await prisma.medication.update({
|
||||
where: { id },
|
||||
data: { active: false },
|
||||
});
|
||||
await auditLog("medication.oneShot.deactivated", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { medicationId: id, eventId: event.id },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.oneShot.deactivated" },
|
||||
meta: { medication_id: id, event_id: event.id },
|
||||
});
|
||||
// Invalidate again so the next list-meds read sees the flip.
|
||||
// v1.5.0 — one-shot lifecycle reconciliation. A `oneShot` medication
|
||||
// has at most one live intake; the helper re-reads the most recent
|
||||
// non-skipped intake and flips `active` to match. Idempotent on
|
||||
// non-one-shot medications (the underlying updateMany is gated by
|
||||
// `oneShot:true`). The flip runs AFTER the intake row is committed
|
||||
// so a flaky write never deactivates a medication that didn't
|
||||
// actually receive its dose.
|
||||
const reconcileAction = await reconcileOneShotState(prisma, id, user.id);
|
||||
if (reconcileAction !== "noop") {
|
||||
invalidateUserMedications(user.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* v1.5.0 — `reconcileOneShotState` unit tests.
|
||||
*
|
||||
* Pins the contract for the lifecycle helper that every intake-mutation
|
||||
* path (POST, PUT, DELETE) tails:
|
||||
*
|
||||
* - one-shot + live intake exists → flip active to false
|
||||
* - one-shot + no live intake → flip active to true
|
||||
* - matching state → noop (no write, no audit)
|
||||
* - non-one-shot medication → noop (the updateMany gate is
|
||||
* `oneShot:true`, so the action is "noop" regardless of state)
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { reconcileOneShotState } from "../lifecycle";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
interface MedRow {
|
||||
oneShot: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
function buildClient(
|
||||
med: MedRow | null,
|
||||
liveIntake: { id: string } | null,
|
||||
) {
|
||||
return {
|
||||
medication: {
|
||||
findUnique: vi.fn().mockResolvedValue(med),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: med?.oneShot ? 1 : 0 }),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findFirst: vi.fn().mockResolvedValue(liveIntake),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("reconcileOneShotState", () => {
|
||||
it("deactivates a one-shot medication when a live intake is found", async () => {
|
||||
const client = buildClient(
|
||||
{ oneShot: true, active: true },
|
||||
{ id: "evt-1" },
|
||||
);
|
||||
const action = await reconcileOneShotState(client, "med-1", "user-1");
|
||||
expect(action).toBe("deactivate");
|
||||
expect(client.medication.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "med-1", userId: "user-1", oneShot: true },
|
||||
data: { active: false },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"medication.oneShot.reconciled",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: { medicationId: "med-1", action: "deactivate" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reactivates a one-shot medication when no live intake remains", async () => {
|
||||
const client = buildClient({ oneShot: true, active: false }, null);
|
||||
const action = await reconcileOneShotState(client, "med-1", "user-1");
|
||||
expect(action).toBe("activate");
|
||||
expect(client.medication.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "med-1", userId: "user-1", oneShot: true },
|
||||
data: { active: true },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"medication.oneShot.reconciled",
|
||||
expect.objectContaining({
|
||||
details: { medicationId: "med-1", action: "activate" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent on a non-one-shot medication", async () => {
|
||||
const client = buildClient(
|
||||
{ oneShot: false, active: true },
|
||||
{ id: "evt-1" },
|
||||
);
|
||||
const action = await reconcileOneShotState(client, "med-1", "user-1");
|
||||
expect(action).toBe("noop");
|
||||
expect(client.medication.updateMany).not.toHaveBeenCalled();
|
||||
expect(client.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent when state already matches (one-shot deactivated, no live intake)", async () => {
|
||||
const client = buildClient({ oneShot: true, active: false }, null);
|
||||
// active matches the "no live intake → should be active=true" expectation? No:
|
||||
// when no live intake exists shouldBeActive=true; current is false → activate.
|
||||
// To pin the "matches" branch we want active:true + live intake exists
|
||||
// (already deactivated state) is one shape; here we test active:false
|
||||
// + no live intake should ACTIVATE (the other direction). So pick the
|
||||
// truly-matching shape below.
|
||||
const action = await reconcileOneShotState(client, "med-1", "user-1");
|
||||
expect(action).toBe("activate");
|
||||
|
||||
// Now the matching branch: one-shot + active:true + no live intake.
|
||||
const matchingClient = buildClient(
|
||||
{ oneShot: true, active: true },
|
||||
null,
|
||||
);
|
||||
const matched = await reconcileOneShotState(
|
||||
matchingClient,
|
||||
"med-1",
|
||||
"user-1",
|
||||
);
|
||||
expect(matched).toBe("noop");
|
||||
expect(matchingClient.medication.updateMany).not.toHaveBeenCalled();
|
||||
// annotate still fires (the breadcrumb is the whole point) but
|
||||
// auditLog stays silent on noop.
|
||||
expect(annotate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns noop when the medication does not exist", async () => {
|
||||
const client = buildClient(null, null);
|
||||
const action = await reconcileOneShotState(client, "ghost", "user-1");
|
||||
expect(action).toBe("noop");
|
||||
expect(client.medication.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* v1.5.0 — one-shot medication lifecycle reconciliation.
|
||||
*
|
||||
* A `oneShot:true` medication carries at most one intake. The audit
|
||||
* surfaced that the auto-deactivate hook on POST was one-way: logging
|
||||
* the dose flipped `active:false`, but DELETE / PUT on the intake event
|
||||
* never re-evaluated the medication state. A user who logged the flu
|
||||
* shot then undid the log was left with a permanently-inactive
|
||||
* medication that dropped off every list / dashboard / worker tick.
|
||||
*
|
||||
* `reconcileOneShotState` is the single helper every intake-mutation
|
||||
* path tails. It re-reads the most recent live (non-skipped, non-null
|
||||
* takenAt) intake for the medication; if one exists the medication is
|
||||
* inactive, otherwise it is reactivated. The Prisma `update` is keyed
|
||||
* on `oneShot:true` so the call is a no-op for every other medication
|
||||
* shape — callers do not need to branch.
|
||||
*
|
||||
* Pure best-effort. A failure to reconcile must not block the intake
|
||||
* mutation itself; callers wrap accordingly.
|
||||
*/
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
interface ReconcilePrisma {
|
||||
medication: {
|
||||
findUnique: (args: {
|
||||
where: { id: string };
|
||||
select: { oneShot: true; active: true };
|
||||
}) => Promise<{ oneShot: boolean; active: boolean } | null>;
|
||||
updateMany: (args: {
|
||||
where: { id: string; userId: string; oneShot: true };
|
||||
data: { active: boolean };
|
||||
}) => Promise<{ count: number }>;
|
||||
};
|
||||
medicationIntakeEvent: {
|
||||
findFirst: (args: {
|
||||
where: {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
skipped: false;
|
||||
takenAt: { not: null };
|
||||
};
|
||||
orderBy: { takenAt: "desc" };
|
||||
select: { id: true };
|
||||
}) => Promise<{ id: string } | null>;
|
||||
};
|
||||
}
|
||||
|
||||
export type ReconcileOneShotAction = "activate" | "deactivate" | "noop";
|
||||
|
||||
/**
|
||||
* Re-evaluate the `active` flag for a one-shot medication after an
|
||||
* intake mutation. Idempotent. Returns the action taken so the caller
|
||||
* can surface it in observability if it cares.
|
||||
*
|
||||
* - "deactivate": medication had a live intake AND was active → flipped to false.
|
||||
* - "activate": medication had no live intake AND was inactive → flipped to true.
|
||||
* - "noop": state already matched OR medication is not one-shot.
|
||||
*/
|
||||
export async function reconcileOneShotState(
|
||||
prismaClient: ReconcilePrisma,
|
||||
medicationId: string,
|
||||
userId: string,
|
||||
): Promise<ReconcileOneShotAction> {
|
||||
const med = await prismaClient.medication.findUnique({
|
||||
where: { id: medicationId },
|
||||
select: { oneShot: true, active: true },
|
||||
});
|
||||
if (!med || !med.oneShot) return "noop";
|
||||
|
||||
const liveIntake = await prismaClient.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
medicationId,
|
||||
skipped: false,
|
||||
takenAt: { not: null },
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const shouldBeActive = liveIntake === null;
|
||||
|
||||
if (med.active === shouldBeActive) {
|
||||
annotate({
|
||||
action: { name: "medication.oneShot.reconciled" },
|
||||
meta: { medication_id: medicationId, action: "noop" },
|
||||
});
|
||||
return "noop";
|
||||
}
|
||||
|
||||
const action: ReconcileOneShotAction = shouldBeActive ? "activate" : "deactivate";
|
||||
|
||||
// `updateMany` with `oneShot:true` is structurally a no-op on every
|
||||
// non-one-shot row; collapse to zero count when the medication is
|
||||
// not actually one-shot (defence-in-depth against the early-return).
|
||||
await prismaClient.medication.updateMany({
|
||||
where: { id: medicationId, userId, oneShot: true },
|
||||
data: { active: shouldBeActive },
|
||||
});
|
||||
|
||||
await auditLog("medication.oneShot.reconciled", {
|
||||
userId,
|
||||
details: { medicationId, action },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.oneShot.reconciled" },
|
||||
meta: { medication_id: medicationId, action },
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
Reference in new issue
Block a user