mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Let a person name their own medication categories
A medication can be filed under a category the person named, next to the built-in ones (#1041). The labels live in medication_category_labels, encrypted at rest, capped at 20 per account; a medication filed under one carries its custom:<uuid> key in the category column. - GET/POST /api/medications/categories (POST idempotent, 30/min per actor, admitted at MANAGE like the cycle symptom vocabulary) and owner-only PATCH/DELETE /api/medications/categories/{key}. DELETE moves every medication filed under the category to OTHER in the same transaction and answers movedCount. - category is widened to a built-in value or custom:<uuid> on the wire, the create and update bodies refuse a key that is not the caller's (422 medications.category.unknown), and list, detail, the insights medication tiles and the MCP medications resource carry categoryLabel. A key with no owned label reads as OTHER and is counted on the event. - The wizard's class step lists the person's categories under the built-in rows with an inline add row; the list page gains a category filter (session only) and a manage sheet for rename, hide and delete. - Backup carries customMedicationCategories (decrypted labels) and the restore writes them before the medications; wipe plan, encrypted column registry, rotation script, sharing guards and OpenAPI follow.
This commit is contained in:
1 parent
350c646463
commit
4cb8118802
55 files changed
+3201
-187
No files matched your search
@@ -97,7 +97,7 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
getMedicationCategories: vi.fn(async () => ({})),
|
||||
resolveMedicationCategories: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
// The route no longer gates on the `insights` module (the AI analysis
|
||||
@@ -117,7 +117,7 @@ import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { resolveMedicationCategories } from "@/lib/medication-category";
|
||||
import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -922,8 +922,8 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing (
|
||||
(
|
||||
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(intakeEvents);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({
|
||||
"med-bp": "BLOOD_PRESSURE",
|
||||
vi.mocked(resolveMedicationCategories).mockResolvedValue({
|
||||
"med-bp": { category: "BLOOD_PRESSURE", categoryLabel: null },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1127,8 +1127,8 @@ describe("GET /api/insights/comprehensive — an unlogged day is not a zero", ()
|
||||
skipped: e.skipped,
|
||||
})),
|
||||
);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({
|
||||
"med-bp": "BLOOD_PRESSURE",
|
||||
vi.mocked(resolveMedicationCategories).mockResolvedValue({
|
||||
"med-bp": { category: "BLOOD_PRESSURE", categoryLabel: null },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { resolveMedicationCategories } from "@/lib/medication-category";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
|
||||
import {
|
||||
@@ -401,9 +401,12 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const medications = medicationRows.filter(expectsDoses);
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
const resolvedCategories = await resolveMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
);
|
||||
const categoryMap = Object.fromEntries(
|
||||
Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]),
|
||||
);
|
||||
|
||||
const medCompliance = [];
|
||||
const bpMedicationEvents: Array<{
|
||||
@@ -461,6 +464,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
category: categoryMap[med.id] ?? "OTHER",
|
||||
// v1.40 (#1041) — the label of a custom category; null for a built-in.
|
||||
categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null,
|
||||
compliance7: c7.rate,
|
||||
compliance30: c30.rate,
|
||||
streak: c7.streak,
|
||||
|
||||
@@ -52,7 +52,9 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
deleteMedicationCategory: vi.fn().mockResolvedValue(undefined),
|
||||
getMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
setMedicationCategory: vi.fn().mockResolvedValue(undefined),
|
||||
resolveMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
isAssignableCategory: vi.fn().mockResolvedValue(true),
|
||||
setMedicationCategory: vi.fn().mockResolvedValue("OTHER"),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -70,7 +72,12 @@ vi.mock("next/headers", () => ({
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import {
|
||||
getMedicationCategories,
|
||||
isAssignableCategory,
|
||||
resolveMedicationCategories,
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
@@ -95,6 +102,12 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// v1.40 — the category helpers' defaults, which `resetAllMocks` clears.
|
||||
vi.mocked(resolveMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(isAssignableCategory).mockResolvedValue(true);
|
||||
vi.mocked(setMedicationCategory).mockImplementation(
|
||||
async (_id, category) => (category ?? "OTHER") as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
|
||||
@@ -14,7 +14,8 @@ import { updateMedicationSchema } from "@/lib/validations/medication";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
deleteMedicationCategory,
|
||||
getMedicationCategories,
|
||||
isAssignableCategory,
|
||||
resolveMedicationCategories,
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
@@ -103,9 +104,11 @@ export const GET = apiHandler(
|
||||
}
|
||||
|
||||
let category = "OTHER";
|
||||
let categoryLabel: string | null = null;
|
||||
try {
|
||||
const categories = await getMedicationCategories([id]);
|
||||
category = categories[id] ?? "OTHER";
|
||||
const categories = await resolveMedicationCategories([id]);
|
||||
category = categories[id]?.category ?? "OTHER";
|
||||
categoryLabel = categories[id]?.categoryLabel ?? null;
|
||||
} catch {
|
||||
// Category enrichment is optional
|
||||
}
|
||||
@@ -242,6 +245,7 @@ export const GET = apiHandler(
|
||||
// intake tracking is off (see `scheduleWireFields`).
|
||||
...scheduleWireFields(medication.trackIntake, schedulesDto),
|
||||
category,
|
||||
categoryLabel,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
// v1.39.4 (#1040) — see `resolveIntakeActionability`.
|
||||
@@ -383,6 +387,16 @@ export const PUT = apiHandler(
|
||||
reminderGraceMinutes: topLevelGraceMinutes,
|
||||
} = input;
|
||||
|
||||
// v1.40 (#1041) — a custom category must be one of this record's own.
|
||||
if (
|
||||
category !== undefined &&
|
||||
!(await isAssignableCategory(user.id, category))
|
||||
) {
|
||||
return apiError("Unknown medication category", 422, {
|
||||
errorCode: "medications.category.unknown",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.39.1 (#1033) — intake tracking transition. Off keeps the schedule
|
||||
// as a record; the live era from here on expects nothing.
|
||||
const wasTracked = existing.trackIntake !== false;
|
||||
@@ -988,10 +1002,9 @@ export const PUT = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCategory =
|
||||
category !== undefined
|
||||
? await setMedicationCategory(id, category)
|
||||
: ((await getMedicationCategories([id]))[id] ?? "OTHER");
|
||||
if (category !== undefined) await setMedicationCategory(id, category);
|
||||
const resolvedCategory = (await resolveMedicationCategories([id]))[id];
|
||||
const normalizedCategory = resolvedCategory?.category ?? "OTHER";
|
||||
|
||||
await auditLog("medication.update", {
|
||||
userId: user.id,
|
||||
@@ -1054,6 +1067,7 @@ export const PUT = apiHandler(
|
||||
),
|
||||
),
|
||||
category: normalizedCategory,
|
||||
categoryLabel: resolvedCategory?.categoryLabel ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -53,7 +53,9 @@ vi.mock("@/lib/cache/server-cache", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
getMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
setMedicationCategory: vi.fn().mockResolvedValue(undefined),
|
||||
resolveMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
isAssignableCategory: vi.fn().mockResolvedValue(true),
|
||||
setMedicationCategory: vi.fn().mockResolvedValue("OTHER"),
|
||||
}));
|
||||
vi.mock("@/lib/tz/local-day", async (importOriginal) => ({
|
||||
// The schedule engine reads the real local-day helpers; only the today
|
||||
@@ -81,7 +83,12 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { cachedSwr } from "@/lib/cache/server-cache";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import {
|
||||
getMedicationCategories,
|
||||
isAssignableCategory,
|
||||
resolveMedicationCategories,
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -99,6 +106,12 @@ function postReq(body: unknown): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// v1.40 — the category helpers' defaults, which `resetAllMocks` clears.
|
||||
vi.mocked(resolveMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(isAssignableCategory).mockResolvedValue(true);
|
||||
vi.mocked(setMedicationCategory).mockImplementation(
|
||||
async (_id, category) => (category ?? "OTHER") as never,
|
||||
);
|
||||
// v1.32.25 — default the mirror growth-guard count below the cap so the
|
||||
// pre-existing mirror-create tests hit the create path, not the guard.
|
||||
vi.mocked(prisma.medication.count).mockResolvedValue(0 as never);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* v1.40 (#1041) — rename, reorder, hide or delete one of the person's own
|
||||
* medication categories.
|
||||
*
|
||||
* Owner-only, like the cycle symptom vocabulary's edit and delete: a
|
||||
* delegate may add a category from the wizard (the POST beside this file is
|
||||
* admitted at MANAGE), but renaming or removing the owner's vocabulary stays
|
||||
* with the owner. Both handlers resolve the key against the caller's own rows
|
||||
* only; another account's key, or a built-in value, is a 404. DELETE moves every medication
|
||||
* filed under the category to OTHER in the same transaction and answers how
|
||||
* many moved, so the app never leaves a medication pointing at a key that no
|
||||
* longer exists. The label is never logged.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
decryptCategoryLabel,
|
||||
deleteMedicationCategoryLabel,
|
||||
encryptCategoryLabel,
|
||||
} from "@/lib/medication-category";
|
||||
import {
|
||||
isCustomMedicationCategoryKey,
|
||||
updateMedicationCategoryLabelSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ key: string }> };
|
||||
|
||||
async function rateLimited(userId: string): Promise<boolean> {
|
||||
const rl = await checkRateLimit(
|
||||
`medications:category:custom:${userId}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
return !rl.allowed;
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
if (await rateLimited(user.id)) {
|
||||
return apiError("Too many requests, try again later", 429);
|
||||
}
|
||||
|
||||
const { key } = await params;
|
||||
if (!isCustomMedicationCategoryKey(key)) {
|
||||
return apiError("Category not found", 404);
|
||||
}
|
||||
|
||||
const { data: raw, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = updateMedicationCategoryLabelSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medications.category.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const owned = await prisma.medicationCategoryLabel.findFirst({
|
||||
where: { key, userId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) return apiError("Category not found", 404);
|
||||
|
||||
const updated = await prisma.medicationCategoryLabel.update({
|
||||
where: { id: owned.id },
|
||||
data: {
|
||||
...(parsed.data.label !== undefined
|
||||
? { labelEncrypted: encryptCategoryLabel(parsed.data.label) }
|
||||
: {}),
|
||||
...(parsed.data.sortOrder !== undefined
|
||||
? { sortOrder: parsed.data.sortOrder }
|
||||
: {}),
|
||||
...(parsed.data.isActive !== undefined
|
||||
? { isActive: parsed.data.isActive }
|
||||
: {}),
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
labelEncrypted: true,
|
||||
sortOrder: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
const medicationCount = await prisma.medicationCategoryAssignment.count({
|
||||
where: { category: key, medication: { userId: user.id } },
|
||||
});
|
||||
|
||||
await auditLog("medication.category.custom.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { key, fields: Object.keys(parsed.data) },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.category.custom.update" },
|
||||
meta: { key },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
key: updated.key,
|
||||
label: decryptCategoryLabel(updated.labelEncrypted),
|
||||
sortOrder: updated.sortOrder,
|
||||
isActive: updated.isActive,
|
||||
medicationCount,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
if (await rateLimited(user.id)) {
|
||||
return apiError("Too many requests, try again later", 429);
|
||||
}
|
||||
|
||||
const { key } = await params;
|
||||
if (!isCustomMedicationCategoryKey(key)) {
|
||||
return apiError("Category not found", 404);
|
||||
}
|
||||
|
||||
const result = await deleteMedicationCategoryLabel(user.id, key);
|
||||
if (!result) return apiError("Category not found", 404);
|
||||
|
||||
await auditLog("medication.category.custom.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { key, movedCount: result.movedCount },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.category.custom.delete" },
|
||||
meta: { key, movedCount: result.movedCount },
|
||||
});
|
||||
|
||||
return apiSuccess({ key, movedCount: result.movedCount });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.40 (#1041) — the person's own medication categories.
|
||||
*
|
||||
* GET /api/medications/categories — every custom category of the record,
|
||||
* hidden ones included, in picker order, labels decrypted, each with
|
||||
* the number of medications filed under it.
|
||||
* POST /api/medications/categories — create one (`{ label }`). Mints a
|
||||
* `custom:<uuid>` key and encrypts the label at rest. Capped per
|
||||
* account (hidden ones count).
|
||||
*
|
||||
* The label is the person's free text, so it never reaches a wide event or an
|
||||
* audit row; only the key does.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
encryptCategoryLabel,
|
||||
listMedicationCategoryLabels,
|
||||
mintCustomMedicationCategoryKey,
|
||||
} from "@/lib/medication-category";
|
||||
import {
|
||||
MAX_CUSTOM_MEDICATION_CATEGORIES,
|
||||
createMedicationCategoryLabelSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
const categories = await listMedicationCategoryLabels(user.id);
|
||||
annotate({
|
||||
action: { name: "medication.category.custom.list" },
|
||||
meta: { count: categories.length },
|
||||
});
|
||||
return apiSuccess({ categories });
|
||||
});
|
||||
|
||||
/**
|
||||
* Wrapped in `withIdempotency`: the key is minted fresh per request and the
|
||||
* label is ciphertext with a per-write IV, so nothing else would catch a
|
||||
* replayed create and the person's list would gain a silent duplicate.
|
||||
*/
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest]>(postMedicationCategory),
|
||||
);
|
||||
|
||||
async function postMedicationCategory(request: NextRequest): Promise<Response> {
|
||||
const { user, actor } = await requireRecordAuth("manage", "medications");
|
||||
|
||||
// Keyed on the actor, the sharing precedent: a manager spends their own
|
||||
// allowance and cannot lock the owner out of their own record.
|
||||
const rl = await checkRateLimit(
|
||||
`medications:category:custom:${actor.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: raw, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = createMedicationCategoryLabelSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medications.category.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const count = await prisma.medicationCategoryLabel.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
if (count >= MAX_CUSTOM_MEDICATION_CATEGORIES) {
|
||||
return apiError(
|
||||
`Custom category limit reached (${MAX_CUSTOM_MEDICATION_CATEGORIES})`,
|
||||
422,
|
||||
{ errorCode: "medications.category.limitReached" },
|
||||
);
|
||||
}
|
||||
|
||||
const created = await prisma.medicationCategoryLabel.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
key: mintCustomMedicationCategoryKey(),
|
||||
labelEncrypted: encryptCategoryLabel(parsed.data.label),
|
||||
sortOrder: count,
|
||||
},
|
||||
select: { key: true, sortOrder: true, isActive: true },
|
||||
});
|
||||
|
||||
await auditLog("medication.category.custom.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { key: created.key },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.category.custom.create" },
|
||||
meta: { key: created.key },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
key: created.key,
|
||||
label: parsed.data.label,
|
||||
sortOrder: created.sortOrder,
|
||||
isActive: created.isActive,
|
||||
medicationCount: 0,
|
||||
},
|
||||
201,
|
||||
);
|
||||
}
|
||||
@@ -10,13 +10,17 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { createMedicationSchema } from "@/lib/validations/medication";
|
||||
import {
|
||||
createMedicationSchema,
|
||||
isCustomMedicationCategoryKey,
|
||||
} from "@/lib/validations/medication";
|
||||
import {
|
||||
unstableExternalIdMeta,
|
||||
unstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import {
|
||||
getMedicationCategories,
|
||||
isAssignableCategory,
|
||||
resolveMedicationCategories,
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
@@ -78,9 +82,11 @@ async function respondWithExistingMirror(
|
||||
},
|
||||
): Promise<Response> {
|
||||
let category = "OTHER";
|
||||
let categoryLabel: string | null = null;
|
||||
try {
|
||||
const categories = await getMedicationCategories([medication.id]);
|
||||
category = categories[medication.id] ?? "OTHER";
|
||||
const categories = await resolveMedicationCategories([medication.id]);
|
||||
category = categories[medication.id]?.category ?? "OTHER";
|
||||
categoryLabel = categories[medication.id]?.categoryLabel ?? null;
|
||||
} catch {
|
||||
getEvent()?.addWarning("Medication categories could not be loaded");
|
||||
}
|
||||
@@ -107,6 +113,7 @@ async function respondWithExistingMirror(
|
||||
),
|
||||
),
|
||||
category,
|
||||
categoryLabel,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,6 +181,16 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
trackIntake,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.40 (#1041) — a custom category must be one of this record's own.
|
||||
if (
|
||||
category !== undefined &&
|
||||
!(await isAssignableCategory(user.id, category))
|
||||
) {
|
||||
return apiError("Unknown medication category", 422, {
|
||||
errorCode: "medications.category.unknown",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.28 — idempotent mirror create. A mirrored medication (Apple
|
||||
// Health) is keyed by the `(userId, externalSource, externalId)`
|
||||
// unique; a re-post of the same concept id (iOS re-sync, retry after a
|
||||
@@ -413,6 +430,10 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
medication.id,
|
||||
category,
|
||||
);
|
||||
const categoryLabel = isCustomMedicationCategoryKey(normalizedCategory)
|
||||
? ((await resolveMedicationCategories([medication.id]))[medication.id]
|
||||
?.categoryLabel ?? null)
|
||||
: null;
|
||||
|
||||
await auditLog("medication.create", {
|
||||
userId: user.id,
|
||||
@@ -446,6 +467,7 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
),
|
||||
),
|
||||
category: normalizedCategory,
|
||||
categoryLabel,
|
||||
},
|
||||
201,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user