mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge branch 'v143/fixdata' into release/v1.43.0
This commit is contained in:
commit
a806676a71
62 files changed
+2018
-209
No files matched your search
@@ -66,10 +66,14 @@ const post = (body: unknown) =>
|
||||
const NOW = new Date("2026-10-16T12:00:00Z");
|
||||
const send = vi.fn();
|
||||
|
||||
function settings(dryRun: unknown, method = "hour_mean_v1") {
|
||||
function settings(
|
||||
dryRun: unknown,
|
||||
method = "hour_mean_v1",
|
||||
appliedAt: Date | null = null,
|
||||
) {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
pulseDayMeanMethod: method,
|
||||
pulseDayMeanAppliedAt: null,
|
||||
pulseDayMeanAppliedAt: appliedAt,
|
||||
pulseDayMeanDryRunJson: dryRun,
|
||||
} as never);
|
||||
}
|
||||
@@ -136,6 +140,7 @@ describe("GET", () => {
|
||||
method: "hour_mean_v1",
|
||||
appliedAt: null,
|
||||
lastDryRun: doneAt("2026-10-15T08:00:00.000Z"),
|
||||
lastRebuild: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,7 +204,9 @@ describe("POST dry_run", () => {
|
||||
const res = await post({ action: "dry_run" });
|
||||
expect(res.status).toBe(409);
|
||||
expect(vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0]).toEqual(
|
||||
expect.objectContaining({ update: { pulseDayMeanDryRunJson: previous } }),
|
||||
expect.objectContaining({
|
||||
update: { pulseDayMeanDryRunJson: { ...previous, rebuild: null } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -264,10 +271,71 @@ describe("POST apply", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST rebuild", () => {
|
||||
const failedRebuild = {
|
||||
status: "failed",
|
||||
startedAt: "2026-10-15T09:00:00.000Z",
|
||||
finishedAt: "2026-10-15T09:05:00.000Z",
|
||||
accounts: null,
|
||||
rows: null,
|
||||
};
|
||||
|
||||
it("is refused before any apply", async () => {
|
||||
settings(doneAt("2026-10-15T08:00:00.000Z"));
|
||||
const res = await post({ action: "rebuild" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe(
|
||||
"maintenance.pulseDayMean.dryRunRequired",
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the recalculation again after a stopped one, without a new dry run", async () => {
|
||||
// The dry run is three weeks old: apply would refuse it, a rebuild does not.
|
||||
settings(
|
||||
{ ...doneAt("2026-09-20T08:00:00.000Z"), rebuild: failedRebuild },
|
||||
"weighted_hour_mean_v2",
|
||||
new Date("2026-09-20T09:00:00.000Z"),
|
||||
);
|
||||
const res = await post({ action: "rebuild" });
|
||||
expect(res.status).toBe(202);
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"pulse-day-mean",
|
||||
{ action: "rebuild" },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(
|
||||
vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0],
|
||||
).toMatchObject({
|
||||
update: {
|
||||
pulseDayMeanDryRunJson: { rebuild: { status: "queued" } },
|
||||
},
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.pulseDayMean.rebuild",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a rebuild left queued with no job behind it as failed", async () => {
|
||||
settings(
|
||||
{
|
||||
...doneAt("2026-10-15T08:00:00.000Z"),
|
||||
rebuild: { ...failedRebuild, status: "running", finishedAt: null },
|
||||
},
|
||||
"weighted_hour_mean_v2",
|
||||
new Date("2026-10-15T09:00:00.000Z"),
|
||||
);
|
||||
const body = await (await get()).json();
|
||||
expect(body.data.lastRebuild.status).toBe("failed");
|
||||
expect(body.data.lastDryRun.status).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST body", () => {
|
||||
it("is strict", async () => {
|
||||
expect((await post({ action: "apply", force: true })).status).toBe(422);
|
||||
expect((await post({ action: "rebuild" })).status).toBe(422);
|
||||
expect((await post({ action: "purge" })).status).toBe(422);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* GET answers the active method, when it was applied and the last dry run
|
||||
* (status and result: per-account ordinal labels only, never an id). POST
|
||||
* `{ action: "dry_run" | "apply" }` answers 202 `{ enqueued }`: a dry run
|
||||
* `{ action: "dry_run" | "apply" | "rebuild" }` answers 202 `{ enqueued }`: a dry run
|
||||
* computes both methods for every account and day and writes nothing but its
|
||||
* result; `apply` needs a successful dry run from the last seven days (409
|
||||
* `maintenance.pulseDayMean.dryRunRequired`), switches the method and
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
PULSE_DAY_MEAN_SEND_OPTIONS,
|
||||
readPulseDayMeanStatus,
|
||||
writeDryRun,
|
||||
writeRebuild,
|
||||
type PulseDayMeanPayload,
|
||||
} from "@/lib/jobs/pulse-day-mean";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import {
|
||||
hasFreshDryRun,
|
||||
pulseDayMeanActionSchema,
|
||||
type PulseDayMeanStatus,
|
||||
} from "@/lib/validations/pulse-day-mean";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -54,6 +56,33 @@ function busy() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue the rebuild and record it as queued, so the card shows it from the
|
||||
* moment it is asked for and a rebuild that never starts reads as failed.
|
||||
* When another run holds the singleton, the previous rebuild state is put
|
||||
* back and the answer is false.
|
||||
*/
|
||||
async function sendRebuild(
|
||||
boss: NonNullable<ReturnType<typeof getGlobalBoss>>,
|
||||
previous: PulseDayMeanStatus["lastRebuild"],
|
||||
): Promise<boolean> {
|
||||
await writeRebuild({
|
||||
status: "queued",
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
accounts: null,
|
||||
rows: null,
|
||||
});
|
||||
const payload: PulseDayMeanPayload = { action: "rebuild" };
|
||||
const jobId = await boss.send(
|
||||
PULSE_DAY_MEAN_QUEUE,
|
||||
payload,
|
||||
PULSE_DAY_MEAN_SEND_OPTIONS,
|
||||
);
|
||||
if (jobId === null) await writeRebuild(previous);
|
||||
return jobId !== null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
const status = await readPulseDayMeanStatus();
|
||||
@@ -110,6 +139,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess({ enqueued: true }, 202);
|
||||
}
|
||||
|
||||
if (action === "rebuild") {
|
||||
// The recalculation again, after an apply whose rebuild stopped. The
|
||||
// method is already switched; the dry run that justified it stands.
|
||||
const status = await readPulseDayMeanStatus();
|
||||
if (status.appliedAt === null) {
|
||||
return apiError(
|
||||
"Nothing applied yet; run a dry run and apply first",
|
||||
409,
|
||||
{ errorCode: "maintenance.pulseDayMean.dryRunRequired" },
|
||||
);
|
||||
}
|
||||
const enqueued = await sendRebuild(boss, status.lastRebuild);
|
||||
if (!enqueued) return busy();
|
||||
await auditLog("admin.pulseDayMean.rebuild", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { method: status.method },
|
||||
});
|
||||
annotate({ meta: { pulse_day_mean_enqueued: true } });
|
||||
return apiSuccess({ enqueued: true }, 202);
|
||||
}
|
||||
|
||||
if (!hasFreshDryRun(await readPulseDayMeanStatus())) {
|
||||
return apiError(
|
||||
"Run a dry run first; apply needs one finished in the last seven days",
|
||||
@@ -135,13 +186,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
setActivePulseDayMeanMethod(APPLIED_METHOD);
|
||||
const payload: PulseDayMeanPayload = { action: "rebuild" };
|
||||
const jobId = await boss.send(
|
||||
PULSE_DAY_MEAN_QUEUE,
|
||||
payload,
|
||||
PULSE_DAY_MEAN_SEND_OPTIONS,
|
||||
const enqueued = await sendRebuild(
|
||||
boss,
|
||||
(await readPulseDayMeanStatus()).lastRebuild,
|
||||
);
|
||||
const enqueued = jobId !== null;
|
||||
|
||||
await auditLog("admin.pulseDayMean.apply", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -26,10 +26,14 @@ import {
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditCInstrument } from "@/lib/mental-health/audit-c";
|
||||
import { createAuditC } from "@/lib/profile/life-circumstances";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditCRequestSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
/** How far ahead of the server clock a `takenAt` may lie. */
|
||||
const AUDIT_C_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC));
|
||||
|
||||
async function postAuditC(request: NextRequest): Promise<Response> {
|
||||
@@ -54,11 +58,36 @@ async function postAuditC(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
// The cut-off follows the language of the wording that was answered, and
|
||||
// the only wording this account is ever shown is the one the read route
|
||||
// delivers for its language. The stored language is that one, decided
|
||||
// here; a client naming another wording answered something this server
|
||||
// did not offer (or the account language changed under an open form), and
|
||||
// is refused rather than scored against the wrong table.
|
||||
const offered = auditCInstrument(user.locale ?? "en").locale;
|
||||
if (
|
||||
input.locale !== undefined &&
|
||||
auditCInstrument(input.locale).locale !== offered
|
||||
) {
|
||||
return apiError("The wording answered is not the one offered", 422, {
|
||||
errorCode: "anamnesis.auditC.invalid",
|
||||
});
|
||||
}
|
||||
// An entry dated in the future would hold the "newest" place for as long
|
||||
// as the clock takes to reach it. A few minutes of device clock skew pass.
|
||||
const takenAt = input.takenAt ? new Date(input.takenAt) : undefined;
|
||||
if (takenAt && takenAt.getTime() > Date.now() + AUDIT_C_CLOCK_SKEW_MS) {
|
||||
return apiError("takenAt lies in the future", 422, {
|
||||
errorCode: "anamnesis.auditC.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { result, replayed } = await createAuditC(user.id, {
|
||||
items: input.items,
|
||||
takenAt: input.takenAt ? new Date(input.takenAt) : undefined,
|
||||
takenAt,
|
||||
tz: input.tz,
|
||||
locale: input.locale ?? user.locale ?? "en",
|
||||
locale: offered,
|
||||
source: input.source,
|
||||
externalId: input.externalId,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "@/lib/optimistic-lock";
|
||||
import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate";
|
||||
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
|
||||
import { recomputeSupplementsIfNutrientsTurnedOn } from "@/lib/medications/ingredients/recompute-job";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -140,6 +141,12 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
await recomputeSupplementsIfNutrientsTurnedOn(
|
||||
user.id,
|
||||
existing?.modulePreferencesJson,
|
||||
merged,
|
||||
);
|
||||
|
||||
// A module toggle can add or remove a Health Score pillar, so the
|
||||
// cached composite was computed from a composition that no longer
|
||||
// holds. Without this the score stays stale for up to an hour after a
|
||||
|
||||
@@ -31,6 +31,12 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
enqueueUserMedicationComplianceBackfill: refoldMock,
|
||||
}));
|
||||
|
||||
// The supplement nutrient rows are keyed by the same local day.
|
||||
const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true })));
|
||||
vi.mock("@/lib/medications/ingredients/recompute-job", () => ({
|
||||
enqueueSupplementNutrientRecompute: nutrientMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
@@ -118,6 +124,7 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
const res = await PUT(mkReq({ timezone: "America/New_York" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(refoldMock).toHaveBeenCalledWith("user-1");
|
||||
expect(nutrientMock).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("does not re-fold when the zone is unchanged", async () => {
|
||||
@@ -130,6 +137,7 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(refoldMock).not.toHaveBeenCalled();
|
||||
expect(nutrientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims whitespace before validating", async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
|
||||
import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
@@ -69,6 +70,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// job; best-effort, the singleton key collapses rapid re-picks.
|
||||
if ((before?.timezone ?? null) !== tz) {
|
||||
const refold = await enqueueUserMedicationComplianceBackfill(user.id);
|
||||
// The supplement nutrient rows are keyed by the same local day.
|
||||
await enqueueSupplementNutrientRecompute(user.id);
|
||||
annotate({
|
||||
meta: {
|
||||
timezone_compliance_refold_enqueued: refold.enqueued,
|
||||
|
||||
@@ -40,9 +40,25 @@ vi.mock("@/lib/auth/oidc", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/managed-profiles/claim-cookie", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@/lib/managed-profiles/claim-cookie")
|
||||
>()),
|
||||
readClaimCookie: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/managed-profiles/handover", () => ({
|
||||
isHandoverUsable: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { readClaimCookie } from "@/lib/managed-profiles/claim-cookie";
|
||||
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { getOidcConfig, discoverOidcMetadata } from "@/lib/auth/oidc";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
discoverOidcMetadata,
|
||||
getOidcConfig,
|
||||
} from "@/lib/auth/oidc";
|
||||
|
||||
function makeRequest(path = "/api/auth/oidc/login"): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`);
|
||||
@@ -248,6 +264,34 @@ describe("GET /api/auth/oidc/login?intent=claim (v1.43, #959)", () => {
|
||||
expect(res.cookies.get("oidc_auth_state")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("asks the provider for a fresh sign-in on a claim, and only there", async () => {
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue({
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
});
|
||||
vi.mocked(buildAuthorizationUrl).mockReturnValue(
|
||||
"https://idp.example.com/authorize?x=1",
|
||||
);
|
||||
vi.mocked(readClaimCookie).mockReturnValue({
|
||||
handoverId: "handover-1",
|
||||
binding: "binding-1",
|
||||
} as never);
|
||||
vi.mocked(isHandoverUsable).mockResolvedValue(true);
|
||||
|
||||
await GET(makeRequest("/api/auth/oidc/login?intent=claim"));
|
||||
expect(vi.mocked(buildAuthorizationUrl).mock.calls[0]?.[0]).toMatchObject({
|
||||
forceLogin: true,
|
||||
});
|
||||
|
||||
vi.mocked(buildAuthorizationUrl).mockClear();
|
||||
await GET(makeRequest("/api/auth/oidc/login"));
|
||||
expect(vi.mocked(buildAuthorizationUrl).mock.calls[0]?.[0]).toMatchObject({
|
||||
forceLogin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes the claim's other failures to the claim page too", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
|
||||
@@ -156,6 +156,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
nonce,
|
||||
codeChallenge,
|
||||
redirectUri,
|
||||
// A claim only; an ordinary sign-in keeps single sign-on.
|
||||
forceLogin: claimBinding !== null,
|
||||
});
|
||||
|
||||
const response = NextResponse.redirect(authorizationUrl);
|
||||
|
||||
@@ -50,6 +50,7 @@ const { profile, userUpdate, transaction } = vi.hoisted(() => {
|
||||
};
|
||||
const userUpdate = vi.fn(async () => ({}));
|
||||
const tx = {
|
||||
$queryRaw: vi.fn(async () => []),
|
||||
userHealthProfile: profile,
|
||||
user: { update: userUpdate },
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ const {
|
||||
const profileUpsert = vi.fn(async () => ({ updatedAt: new Date(0) }));
|
||||
const userUpdate = vi.fn(async () => ({}));
|
||||
const tx = {
|
||||
$queryRaw: vi.fn(async () => []),
|
||||
userHealthProfile: {
|
||||
findUnique: profileFindUnique,
|
||||
upsert: profileUpsert,
|
||||
@@ -246,6 +247,7 @@ describe("about-me module gate", () => {
|
||||
transaction.mockImplementationOnce(
|
||||
async (callback: (client: unknown) => unknown) => {
|
||||
const result = await callback({
|
||||
$queryRaw: vi.fn(async () => []),
|
||||
userHealthProfile: {
|
||||
findUnique: profileFindUnique,
|
||||
upsert: txProfileUpsert,
|
||||
|
||||
@@ -42,6 +42,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { lockAiSections } from "@/lib/profile/life-circumstances";
|
||||
import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
@@ -246,6 +247,9 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
// A PUT here replaces the eight sections it lists and leaves the
|
||||
// life-circumstance switches (v1.43) exactly as stored.
|
||||
if (parsed.data.aiIncludedSections !== undefined) {
|
||||
// Taken before the read, so a switch changed concurrently is read
|
||||
// after it commits and carried over as it now stands.
|
||||
await lockAiSections(tx, user.id);
|
||||
const stored = await tx.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { aiIncludedSections: true },
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.mock("@/lib/db", () => ({
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
healthProfileFactRevision: { findMany: vi.fn() },
|
||||
// v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry.
|
||||
mentalHealthAssessment: { findFirst: vi.fn(async () => null) },
|
||||
mentalHealthAssessment: { findMany: vi.fn(async () => []) },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
|
||||
@@ -81,8 +81,10 @@ import {
|
||||
import {
|
||||
classifyHourBucket,
|
||||
HOUR_BUCKET_SKIP_REASONS,
|
||||
combineHourWrites,
|
||||
parseHourBucketId,
|
||||
writeHourBuckets,
|
||||
type HourBucketEntry,
|
||||
type HourBucketWrite,
|
||||
} from "@/lib/measurements/intraday-hour-buckets";
|
||||
import { resolveStoredTimezone } from "@/lib/tz/resolver";
|
||||
@@ -394,7 +396,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
// v1.43 — hourly sums for the intraday profile, and the zone they are cut in
|
||||
// (the account's, the one `/api/auth/me` publishes as `timezone`).
|
||||
const hourWrites: HourBucketWrite[] = [];
|
||||
const hourWrites: HourBucketEntry[] = [];
|
||||
let accountTz: string | undefined;
|
||||
const batchNow = new Date();
|
||||
|
||||
@@ -484,6 +486,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
dateKey: hourId.dateKey,
|
||||
hour: hourId.hour,
|
||||
value: hourMapped.value,
|
||||
startMs: startDate.getTime(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -984,28 +987,24 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.43 — the hours, after the measurements. The last entry for one slot
|
||||
// wins, as for the day rows; an earlier one in the same batch is a
|
||||
// superseded duplicate.
|
||||
// v1.43 — the hours, after the measurements. One write per slot: the last
|
||||
// entry for one start wins (an earlier one in the same batch is a
|
||||
// superseded duplicate), and the two starts of the hour a fall-back day
|
||||
// repeats are summed (`combineHourWrites`).
|
||||
let hourInsertedCount = 0;
|
||||
let hourUpdatedCount = 0;
|
||||
const hourLanded: HourBucketWrite[] = [];
|
||||
if (hourWrites.length > 0 && accountTz) {
|
||||
const lastBySlot = new Map<string, HourBucketWrite>();
|
||||
for (const w of hourWrites) {
|
||||
lastBySlot.set(`${w.type}::${w.dateKey}::${w.hour}`, w);
|
||||
const { writes, superseded } = combineHourWrites(hourWrites);
|
||||
for (const index of superseded) {
|
||||
results[index] = {
|
||||
index,
|
||||
status: "duplicate",
|
||||
reason: "superseded_in_batch",
|
||||
};
|
||||
duplicateCount++;
|
||||
}
|
||||
for (const w of hourWrites) {
|
||||
if (lastBySlot.get(`${w.type}::${w.dateKey}::${w.hour}`) !== w) {
|
||||
results[w.index] = {
|
||||
index: w.index,
|
||||
status: "duplicate",
|
||||
reason: "superseded_in_batch",
|
||||
};
|
||||
duplicateCount++;
|
||||
}
|
||||
}
|
||||
const toWrite = [...lastBySlot.values()];
|
||||
const toWrite = writes.map(({ write }) => write);
|
||||
try {
|
||||
const statuses = await writeHourBuckets(
|
||||
prisma,
|
||||
@@ -1013,21 +1012,25 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
accountTz,
|
||||
toWrite,
|
||||
);
|
||||
for (const w of toWrite) {
|
||||
const status = statuses.get(w.index) ?? "inserted";
|
||||
results[w.index] = { index: w.index, status };
|
||||
if (status === "inserted") hourInsertedCount++;
|
||||
else hourUpdatedCount++;
|
||||
hourLanded.push(w);
|
||||
for (const { write, contributors } of writes) {
|
||||
const status = statuses.get(write.index) ?? "inserted";
|
||||
for (const index of contributors) {
|
||||
results[index] = { index, status };
|
||||
if (status === "inserted") hourInsertedCount++;
|
||||
else hourUpdatedCount++;
|
||||
}
|
||||
hourLanded.push(write);
|
||||
}
|
||||
} catch (err) {
|
||||
for (const w of toWrite) {
|
||||
results[w.index] = {
|
||||
index: w.index,
|
||||
status: "failed",
|
||||
reason: "write_failed",
|
||||
};
|
||||
failedCount++;
|
||||
for (const { contributors } of writes) {
|
||||
for (const index of contributors) {
|
||||
results[index] = {
|
||||
index,
|
||||
status: "failed",
|
||||
reason: "write_failed",
|
||||
};
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
annotate({
|
||||
action: { name: "measurement.batch.hour-bucket.failed" },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job";
|
||||
import {
|
||||
apiHandler,
|
||||
requireGuardianAuth,
|
||||
@@ -239,6 +240,8 @@ export const PATCH = apiHandler(
|
||||
}
|
||||
if (patch.timezone !== undefined) {
|
||||
invalidateUserMedications(access.recordId, { evict: true });
|
||||
// The supplement nutrient rows are keyed by the local day.
|
||||
await enqueueSupplementNutrientRecompute(access.recordId);
|
||||
}
|
||||
settings = {
|
||||
...record,
|
||||
|
||||
Reference in new issue
Block a user