Merge remote-tracking branch 'origin/main' into feat/documents-coach-bundle

This commit is contained in:
Marc-André Bombeck committed 2026-07-17 00:02:51 +02:00
commit d470d406a7
35 files changed
+2390 -41

No files matched your search

+18
View File
@@ -34,6 +34,7 @@ import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/digest";
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
interface RouteCtx {
@@ -94,6 +95,23 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
: null;
}
// S3 (§2.3.1) — every accepted plan earns a check-in. When the coach
// activates a plan and set no review date of its own, default `reviewDate` to
// +COACH_CHECKIN_REVIEW_DAYS so the Today check-in card fires in a week. Read
// the current row first so an explicitly-pinned review date is never
// clobbered; the read stays owner-scoped, so it leaks no cross-account state.
if (parsed.data.status === "active" && parsed.data.reviewDate === undefined) {
const current = await prisma.coachPlan.findFirst({
where: { id, userId: user.id, deletedAt: null },
select: { reviewDate: true },
});
if (current && current.reviewDate === null) {
data.reviewDate = new Date(
Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000,
);
}
}
// `updateMany` (not `update`) so an unknown / cross-user / already-deleted id
// is a 0-count no-op rather than a P2025 throw — the existence channel never
// leaks across accounts.
@@ -315,7 +315,68 @@ describe("PATCH /api/coach/plans/[id]", () => {
expect(arg.where.id).toBe("p1");
expect(arg.where.userId).toBe("user-1");
expect(arg.where.deletedAt).toBeNull();
// Only the lifecycle field is written — never metric / encrypted text.
// S3 (§2.3.1): activating a plan the coach never dated defaults a review
// checkpoint ~7d out. Only lifecycle fields are written (status +
// reviewDate) — never metric / encrypted text (no mass assignment).
expect(new Set(Object.keys(arg.data))).toEqual(
new Set(["status", "reviewDate"]),
);
expect(arg.data.status).toBe("active");
expect(arg.data.reviewDate).toBeInstanceOf(Date);
const days =
((arg.data.reviewDate as Date).getTime() - Date.now()) / 86_400_000;
expect(days).toBeGreaterThan(6.5);
expect(days).toBeLessThan(7.5);
});
it("does NOT clobber a review date the caller pinned on activate", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({
id: "p1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
targetEncrypted: null,
status: "active",
reviewDate: new Date("2026-08-01T00:00:00Z"),
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-03T00:00:00Z"),
} as never);
const res = await callPatch({
status: "active",
reviewDate: "2026-08-01T00:00:00.000Z",
});
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
data: { reviewDate: Date };
};
// The pinned date is honoured verbatim, not the +7d default.
expect(arg.data.reviewDate.toISOString()).toBe("2026-08-01T00:00:00.000Z");
});
it("does NOT default a review date when marking a plan met", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({
id: "p1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
targetEncrypted: null,
status: "met",
reviewDate: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-03T00:00:00Z"),
} as never);
await callPatch({ status: "met" });
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
data: Record<string, unknown>;
};
expect(Object.keys(arg.data)).toEqual(["status"]);
});
+17
View File
@@ -64,6 +64,7 @@ import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
@@ -764,6 +765,22 @@ async function postBatch(request: NextRequest): Promise<Response> {
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
// S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION
// row for last night just landed, kick the debounced morning refresh so the
// digest/score finalise with the current sleep. The trigger judges "last
// night" in the user's profile tz, so a historical Apple export replaying
// months of nights never re-triggers for an old night. Fire-and-forget.
void maybeEnqueueMorningRefresh(
user.id,
prepared
.filter((p) => p.row.type === "SLEEP_DURATION")
.map((p) =>
p.row.measuredAt instanceof Date
? p.row.measuredAt
: new Date(p.row.measuredAt as string),
),
).catch(() => {});
}
return apiSuccess({