mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC midnight are the idioms behind every day-boundary fix this year: the evening west of UTC and the small hours east of it land on the neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in src/ outside src/lib/tz and tests, and refuses a disable directive for itself that is not next-line or gives no reason. The time zone module now names every legitimate form: userDayKey for a person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey / isCalendarDateKey / statedDateKey for key arithmetic and parsing. Of the 133 sites the rule found, 84 are gone. Most moved onto a named helper with no change in behaviour; these changed behaviour: - cycle calendar, insights, Coach snapshot and prediction refresh start their lookbacks at the first instant of the user's day - a model-transcribed date like "June 10, 2026" no longer lands a day early in a process east of UTC (documents and lab scans) - a custom report range covers exactly the picked days in the profile zone instead of starting the evening before west of UTC - date-of-birth pickers cap at the user's today, not the UTC one - an allergy onset date is stored at noon UTC like other date-only values - the report's cycle summary, the Coach's usual BP range and the intake projector's rollup recompute use the user's day - vaccination dates render as dates instead of the previous evening 49 remain, each with its reason on the line above: 39 UTC by design (15 file name stamps, rollup and object keys, the UTC-day score engines) and 10 marked baseline. A guard test requires a classified reason on every directive and holds the baseline to a ceiling that only goes down.
84 lines
3.0 KiB
TypeScript
84 lines
3.0 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
|
|
import {
|
|
apiHandler,
|
|
MFA_STEP_UP_MAX_AGE_SECONDS,
|
|
requireFreshMfa,
|
|
} from "@/lib/api-handler";
|
|
import {
|
|
apiError,
|
|
apiSuccess,
|
|
returnAllZodIssues,
|
|
safeJson,
|
|
} from "@/lib/api-response";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
|
import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
|
|
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
|
|
|
/**
|
|
* The same ceiling its guardian sibling carries, and for the same reason.
|
|
*
|
|
* `POST /api/managed-profiles/{id}/guardians` has been rate-limited at ten an
|
|
* hour since it shipped; this route creates a real account row on every call
|
|
* and had none. A person cannot mistype their way into a hundred accounts, but
|
|
* a script can, and the two endpoints of one family answering differently to
|
|
* the same abuse is the kind of asymmetry nobody notices until it is used.
|
|
*/
|
|
const CREATE_LIMIT = 10;
|
|
const CREATE_WINDOW_MS = 60 * 60 * 1000;
|
|
|
|
/**
|
|
* Create a record a Guardian administers. This deliberately uses the
|
|
* cookie-only fresh-MFA gate: a Bearer token cannot mint a credential-less
|
|
* person and persistent management relationship.
|
|
*/
|
|
export const POST = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
|
// Before the body is read, exactly as the guardian route does it: the cheap
|
|
// refusal comes first so a flood costs a bucket lookup rather than a parse.
|
|
const rateLimit = await checkRateLimit(
|
|
`managed-profile:create:${user.id}`,
|
|
CREATE_LIMIT,
|
|
CREATE_WINDOW_MS,
|
|
);
|
|
if (!rateLimit.allowed) {
|
|
return apiError("Too many profiles created, try again later", 429);
|
|
}
|
|
const { data: body, error: jsonError } = await safeJson(request, {
|
|
maxBytes: 64 * 1024,
|
|
});
|
|
if (jsonError) return jsonError;
|
|
|
|
const parsed = createManagedProfileSchema.safeParse(body);
|
|
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
|
|
|
const { profile, creatorGrant } = await createManagedProfile({
|
|
creatorId: user.id,
|
|
displayName: parsed.data.displayName,
|
|
dateOfBirth: parsed.data.dateOfBirth
|
|
? dayKeyAsUtcMidnight(parsed.data.dateOfBirth)
|
|
: null,
|
|
locale: parsed.data.locale,
|
|
timezone: parsed.data.timezone,
|
|
// Absent and null both mean "not recorded". The record keeps the same
|
|
// NULL an account that never answered carries, and the cycle module
|
|
// derives its default from it rather than from a guess about the person.
|
|
gender: parsed.data.gender ?? null,
|
|
});
|
|
|
|
await auditLog("managed_profile.created", {
|
|
userId: user.id,
|
|
details: { profileId: profile.id, grantId: creatorGrant.id },
|
|
}).catch(() => {});
|
|
annotate({
|
|
action: { name: "managed_profile.create" },
|
|
meta: { profile_id: profile.id },
|
|
});
|
|
|
|
return apiSuccess(toManagedProfileView(profile), 201);
|
|
});
|