Files
HealthLog/src/app/api/auth/me/source-priority/route.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

135 lines
4.9 KiB
TypeScript

/**
* v1.4.25 — per-user, per-metric-class source priority.
*
* GET /api/auth/me/source-priority — returns the fully-defaulted
* shape (missing keys filled
* from `DEFAULT_SOURCE_PRIORITY`).
* PUT /api/auth/me/source-priority — replaces the persisted shape;
* body is validated against
* `sourcePrioritySchema` and
* persisted as the partial form
* (missing keys keep the default
* at read time).
*
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
* helper. The analytics aggregator reads this row on every call and
* the read path is cheap (one column). The PUT flushes the per-user
* profile-derived caches (`invalidateUserProfile`) because the ladder
* decides which source's rows the cached targets / derived / analytics
* payloads were built from.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserProfile } from "@/lib/cache/invalidate";
import { getSourcesInUse } from "@/lib/integrations/sources-in-use";
import {
parseSourcePriority,
sourcePrioritySchema,
} from "@/lib/validations/source-priority";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.source-priority.get" } });
const [row, inUse] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
}),
getSourcesInUse(user.id),
]);
// `inUse` is read-only context for the editor: which ranked sources this
// account has connected or holds data from. It never feeds the ladder.
return apiSuccess({
...parseSourcePriority(row?.sourcePriorityJson),
inUse,
});
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
return apiError("Invalid JSON body", 400, {
errorCode: "source-priority.body.invalid_json",
});
}
const parsed = sourcePrioritySchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.source-priority.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"source-priority.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "source-priority.body.invalid_shape" },
);
}
// v1.4.25 (security M-3): capture the previous shape
// before overwriting so the audit-log entry records the actual
// delta. Source-priority drives every aggregator's source pick,
// so a silent compromise (or accidental client write) was
// previously invisible — mirroring the timezone-route audit
// pattern closes the gap.
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
});
// Persist the partial form — missing keys read as defaults via
// `parseSourcePriority`. Storing only the user-edited subset keeps
// the Json blob narrow and future-proofs the shape: when a new
// metric class is added, every existing row reads the new key's
// default until the user explicitly changes it.
await prisma.user.update({
where: { id: user.id },
data: { sourcePriorityJson: parsed.data },
});
// The priority ladder drives every aggregator's source pick (the
// analytics rank SQL, the rollup read-swap, the targets grid's sleep
// dedup) — drop the cached payloads so the reorder reflects on the
// next read instead of after the TTL.
invalidateUserProfile(user.id);
await auditLog("user.source-priority.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: before?.sourcePriorityJson ?? null,
next: parsed.data,
},
});
annotate({
action: { name: "auth.me.source-priority.put" },
meta: { keys: Object.keys(parsed.data).length },
});
return apiSuccess({
...parseSourcePriority(parsed.data),
inUse: await getSourcesInUse(user.id),
});
});