mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'main' into feat/encounter-phase-1
This commit is contained in:
commit
a83be074ca
65 files changed
+7864
-624
No files matched your search
@@ -862,6 +862,50 @@ const handler = apiHandler(
|
||||
update: restoredData,
|
||||
});
|
||||
|
||||
// The day context, rebuilt after the entry so it can bind to
|
||||
// whatever id that entry actually got. Delete-then-write rather
|
||||
// than an upsert with a preserve-when-absent arm: the file
|
||||
// states the whole context or states that there was none, and a
|
||||
// restored entry carrying half a file's context and half a live
|
||||
// row's would be a state neither of them describes.
|
||||
await tx.moodContext.deleteMany({
|
||||
where: { moodEntryId: restored.id },
|
||||
});
|
||||
if (entry.context) {
|
||||
const context = entry.context;
|
||||
await tx.moodContext.create({
|
||||
data: {
|
||||
moodEntryId: restored.id,
|
||||
userId: ownerId,
|
||||
workStatus: context.workStatus ?? null,
|
||||
workMinutes: context.workMinutes ?? null,
|
||||
overtimeMinutes: context.overtimeMinutes ?? null,
|
||||
workLoad: context.workLoad ?? null,
|
||||
workSatisfaction: context.workSatisfaction ?? null,
|
||||
contactCircles: context.contactCircles ?? null,
|
||||
contactForm: context.contactForm ?? null,
|
||||
contactExtent: context.contactExtent ?? null,
|
||||
contactQuality: context.contactQuality ?? null,
|
||||
contactSupport: context.contactSupport ?? null,
|
||||
leisureCategories: context.leisureCategories ?? null,
|
||||
leisureMinutes: context.leisureMinutes ?? null,
|
||||
leisureJoy: context.leisureJoy ?? null,
|
||||
leisureRecovery: context.leisureRecovery ?? null,
|
||||
eventType: context.eventType ?? null,
|
||||
eventValence: context.eventValence ?? null,
|
||||
eventAt: context.eventAt ? new Date(context.eventAt) : null,
|
||||
// The note comes back the way every other dual-column
|
||||
// note does: ciphertext verbatim when the file carries
|
||||
// it, a portable file's plain text re-encrypted on the
|
||||
// way in.
|
||||
notesEncrypted:
|
||||
context.notesEncrypted == null
|
||||
? encryptNote(context.note ?? null)
|
||||
: decodeEncryptedBytes(context.notesEncrypted),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.moodEntryTagLink.deleteMany({
|
||||
where: { moodEntryId: restored.id },
|
||||
});
|
||||
|
||||
@@ -11,6 +11,16 @@ const txClient = vi.hoisted(() => ({
|
||||
moodEntryTagLink: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
// v1.38 — the day context is replaced inside the same transaction. Answering
|
||||
// null from `findUnique` keeps this fake honest: what a context write
|
||||
// actually stores is proven against real Postgres in
|
||||
// `tests/integration/mood-context.test.ts`, not by a stub that agrees with
|
||||
// whatever it is asked.
|
||||
moodContext: {
|
||||
deleteMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -114,6 +124,8 @@ beforeEach(() => {
|
||||
);
|
||||
txClient.moodEntry.update.mockResolvedValue(UPDATED_ENTRY as never);
|
||||
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
|
||||
txClient.moodContext.deleteMany.mockResolvedValue({ count: 0 });
|
||||
txClient.moodContext.findUnique.mockResolvedValue(null);
|
||||
vi.mocked(replaceTagLinks).mockResolvedValue(undefined);
|
||||
vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined);
|
||||
// v1.7.0 sync — PUT now looks the row up via `findFirst` with a
|
||||
@@ -296,6 +308,11 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => {
|
||||
moodEntryTagLink: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
moodContext: {
|
||||
deleteMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
vi.mocked(replaceTagLinks).mockImplementationOnce(async () => {
|
||||
draft.binary = ["calm"];
|
||||
|
||||
@@ -16,6 +16,7 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { deriveA1, shapeLevelA } from "@/lib/mood/level-a";
|
||||
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
@@ -47,6 +48,9 @@ export const GET = apiHandler(
|
||||
// (not `findUnique`) because `deletedAt` is not part of a unique index.
|
||||
const entry = await prisma.moodEntry.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
// v1.38 — the day context rides with the entry rather than behind a
|
||||
// second request: the edit surface needs both or neither.
|
||||
include: { context: true },
|
||||
});
|
||||
|
||||
if (!entry || entry.userId !== user.id) {
|
||||
@@ -58,10 +62,13 @@ export const GET = apiHandler(
|
||||
meta: { moodEntryId: id },
|
||||
});
|
||||
|
||||
const { context, ...row } = entry;
|
||||
return apiSuccess({
|
||||
// v1.37 — level-A values under the keys the write path takes.
|
||||
...shapeLevelA(shapeMoodNote(entry)),
|
||||
tags: parseTags(entry.tags),
|
||||
...shapeLevelA(shapeMoodNote(row)),
|
||||
tags: parseTags(row.tags),
|
||||
// v1.38 — lists decoded, note decrypted, ciphertext dropped.
|
||||
context: context ? contextForWire(context) : null,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -201,6 +208,20 @@ export const PUT = apiHandler(
|
||||
);
|
||||
}
|
||||
|
||||
// v1.38 — the day context replaces whole when the request carried
|
||||
// one, and is left alone when it did not, matching the two link sets
|
||||
// above. Inside the same transaction: a context write that fails rolls
|
||||
// the edit and its syncVersion increment back with it.
|
||||
const contextOutcome = await persistMoodContext(
|
||||
tx,
|
||||
id,
|
||||
user.id,
|
||||
data.context,
|
||||
);
|
||||
const storedContext = await tx.moodContext.findUnique({
|
||||
where: { moodEntryId: id },
|
||||
});
|
||||
|
||||
// Return the same split shape as list/create: binary keys are
|
||||
// independent from rated-factor keys and their per-entry scores.
|
||||
const links = await tx.moodEntryTagLink.findMany({
|
||||
@@ -213,6 +234,8 @@ export const PUT = apiHandler(
|
||||
|
||||
return {
|
||||
entry: updated,
|
||||
contextOutcome,
|
||||
persistedContext: storedContext,
|
||||
persistedTagKeys: links
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => link.moodTag.key),
|
||||
@@ -252,8 +275,13 @@ export const PUT = apiHandler(
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const { entry, persistedTagKeys, persistedRatedFactors } =
|
||||
transactionOutcome.result;
|
||||
const {
|
||||
entry,
|
||||
persistedTagKeys,
|
||||
persistedRatedFactors,
|
||||
persistedContext,
|
||||
contextOutcome,
|
||||
} = transactionOutcome.result;
|
||||
|
||||
await auditLog("moodEntry.update", {
|
||||
userId: user.id,
|
||||
@@ -278,6 +306,7 @@ export const PUT = apiHandler(
|
||||
...(data.tags !== undefined ? ["tags"] : []),
|
||||
...(data.tagKeys !== undefined ? ["tagKeys"] : []),
|
||||
...(data.ratedFactors !== undefined ? ["ratedFactors"] : []),
|
||||
...(data.context !== undefined ? ["context"] : []),
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -285,7 +314,7 @@ export const PUT = apiHandler(
|
||||
|
||||
annotate({
|
||||
action: { name: "mood-entries.update" },
|
||||
meta: { moodEntryId: id },
|
||||
meta: { moodEntryId: id, mood_context: contextOutcome },
|
||||
});
|
||||
|
||||
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
|
||||
@@ -328,6 +357,8 @@ export const PUT = apiHandler(
|
||||
// refetch (shape-matches the list GET).
|
||||
tagKeys: persistedTagKeys,
|
||||
ratedFactors: persistedRatedFactors,
|
||||
// v1.38 — the stored context after the edit, or null when there is none.
|
||||
context: persistedContext ? contextForWire(persistedContext) : null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The bulk path's per-entry dimension bound, pinned to the single-entry one.
|
||||
*
|
||||
* The bulk route validates `a1`..`a5` per entry rather than in the batch
|
||||
* schema, because the endpoint's contract is that a bad row is skipped and the
|
||||
* rest of the batch lands. That split buys the tolerance and costs a second
|
||||
* copy of "0 to 10", and a second copy of a bound is a bound that drifts: the
|
||||
* day somebody widens the scale on one path, the other silently keeps
|
||||
* refusing values the product now accepts, and it does it 500 rows at a time.
|
||||
*
|
||||
* So the two are compared here by behaviour rather than by reading each
|
||||
* other's source — the same value is offered to both schemas and they have to
|
||||
* agree on it, at both ends of the scale and one step outside each.
|
||||
*
|
||||
* Mutation check: change the bulk route's `.max(10)` to `.max(9)` and the
|
||||
* boundary row goes red naming 10; change the single-entry `levelADimension`
|
||||
* bound instead and the same row goes red from the other side.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createMoodEntrySchema } from "@/lib/validations/mood";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const BULK_ROUTE = join(
|
||||
ROOT,
|
||||
"src",
|
||||
"app",
|
||||
"api",
|
||||
"mood-entries",
|
||||
"bulk",
|
||||
"route.ts",
|
||||
);
|
||||
|
||||
/**
|
||||
* The bulk schema is module-private, so it is read as source and its bound is
|
||||
* extracted. A matcher that found nothing would prove nothing, which is why
|
||||
* the extraction asserts its own match count before anything is compared.
|
||||
*/
|
||||
function bulkDimensionBound(): { min: number; max: number } {
|
||||
const source = readFileSync(BULK_ROUTE, "utf8");
|
||||
const start = source.indexOf("const bulkDimensionsSchema = z.object({");
|
||||
expect(
|
||||
start,
|
||||
"bulkDimensionsSchema not found — the per-entry parse was renamed and this guard now proves nothing",
|
||||
).toBeGreaterThan(-1);
|
||||
const body = source.slice(start, source.indexOf("\n});", start));
|
||||
const matches = [
|
||||
...body.matchAll(/\.int\(\)\.min\((-?\d+)\)\.max\((\d+)\)/g),
|
||||
];
|
||||
expect(
|
||||
matches.length,
|
||||
"no bounded dimension fields found inside bulkDimensionsSchema",
|
||||
).toBe(5);
|
||||
const bounds = new Set(matches.map((m) => `${m[1]}..${m[2]}`));
|
||||
expect(
|
||||
[...bounds],
|
||||
"the five bulk dimensions do not share one bound",
|
||||
).toHaveLength(1);
|
||||
return { min: Number(matches[0][1]), max: Number(matches[0][2]) };
|
||||
}
|
||||
|
||||
/** Does the single-entry create schema accept this `a1`? */
|
||||
function singleEntryAccepts(a1: number): boolean {
|
||||
return createMoodEntrySchema.safeParse({
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-01T08:00:00.000Z",
|
||||
a1,
|
||||
}).success;
|
||||
}
|
||||
|
||||
describe("bulk dimension bounds agree with the single-entry schema", () => {
|
||||
const bulk = bulkDimensionBound();
|
||||
|
||||
it("reads a plausible bound out of the bulk route", () => {
|
||||
expect(bulk.max).toBeGreaterThan(bulk.min);
|
||||
expect(Number.isInteger(bulk.min)).toBe(true);
|
||||
expect(Number.isInteger(bulk.max)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the same values at both ends of the scale", () => {
|
||||
expect(singleEntryAccepts(bulk.min)).toBe(true);
|
||||
expect(singleEntryAccepts(bulk.max)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses the same values one step outside it", () => {
|
||||
expect(singleEntryAccepts(bulk.min - 1)).toBe(false);
|
||||
expect(singleEntryAccepts(bulk.max + 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,16 @@ const txClient = {
|
||||
moodEntryTagLink: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
// v1.38 — the day context is written inside the same transaction, and the
|
||||
// route reads it back for the response. A fake that answered a row here
|
||||
// regardless of what was written would prove nothing, so `findUnique`
|
||||
// answers null and the context-carrying claims live in the integration
|
||||
// suite (`tests/integration/mood-context.test.ts`) against real Postgres.
|
||||
moodContext: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
upsert: vi.fn(),
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
|
||||
@@ -41,6 +41,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
|
||||
import {
|
||||
getScoreForMood,
|
||||
moodContextSchema,
|
||||
moodLevelEnum,
|
||||
moodSourceEnum,
|
||||
} from "@/lib/validations/mood";
|
||||
@@ -51,7 +52,8 @@ import {
|
||||
type UnstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { levelAForWrite } from "@/lib/mood/level-a";
|
||||
import { persistMoodContext } from "@/lib/mood/context";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
@@ -94,6 +96,39 @@ const bulkEntrySchema = z.object({
|
||||
.max(30)
|
||||
.optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
/**
|
||||
* v1.38 — the five level-A self-state values, each 0-10, each optional.
|
||||
* A batch that carries none still writes complete rows: the server derives
|
||||
* `a1` from the five-point label, so an older client build needs no change
|
||||
* to stay correct.
|
||||
*
|
||||
* `unknown` here and parsed with {@link bulkDimensionsSchema} PER ENTRY in
|
||||
* the loop below, for the same reason `context` is: the doc comment beside
|
||||
* this field used to promise that an out-of-range value marks THIS entry
|
||||
* skipped while the field itself sat in the batch schema and 422'd all five
|
||||
* hundred. The words were right and the code was not. A phone draining a
|
||||
* year of local history must not lose the year to one row.
|
||||
* @per-entry-parse: bulkDimensionsSchema, in the loop below
|
||||
*/
|
||||
a1: z.unknown().optional(),
|
||||
a2: z.unknown().optional(),
|
||||
a3: z.unknown().optional(),
|
||||
a4: z.unknown().optional(),
|
||||
a5: z.unknown().optional(),
|
||||
/**
|
||||
* v1.38 — the day's context. Optional, and absent leaves a stored context
|
||||
* alone on a re-post, matching the single-entry path: a build with no
|
||||
* context surface must not blank one filled in on the web.
|
||||
*
|
||||
* Declared as `unknown` here and parsed with `moodContextSchema` PER ENTRY
|
||||
* in the loop below, deliberately. Validating it in the batch schema would
|
||||
* make one entry carrying a retired key fail all five hundred, and this
|
||||
* endpoint's whole contract — stated in its own OpenAPI description — is
|
||||
* that a bad row is `skipped` and the rest of the batch lands. A phone
|
||||
* draining a year of local history must not lose the year to one row.
|
||||
* @per-entry-parse: moodContextSchema, in the loop below
|
||||
*/
|
||||
context: z.unknown().optional(),
|
||||
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
source: moodSourceEnum.optional().default("MANUAL"),
|
||||
/**
|
||||
@@ -111,6 +146,23 @@ const bulkEntrySchema = z.object({
|
||||
externalId: z.string().min(1).max(120).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* The five dimensions, validated for one entry.
|
||||
*
|
||||
* The bound is the scale itself, matching the single-entry schema exactly —
|
||||
* one definition of "0 to 10" would be better still, but the single-entry
|
||||
* schema states it as a shared const and this one has to answer per entry, so
|
||||
* the two are pinned to each other by
|
||||
* `src/app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts` instead.
|
||||
*/
|
||||
const bulkDimensionsSchema = z.object({
|
||||
a1: z.number().int().min(0).max(10).nullable().optional(),
|
||||
a2: z.number().int().min(0).max(10).nullable().optional(),
|
||||
a3: z.number().int().min(0).max(10).nullable().optional(),
|
||||
a4: z.number().int().min(0).max(10).nullable().optional(),
|
||||
a5: z.number().int().min(0).max(10).nullable().optional(),
|
||||
});
|
||||
|
||||
const bulkPayloadSchema = z.object({
|
||||
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
|
||||
});
|
||||
@@ -275,13 +327,53 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The context, parsed for THIS entry. A value outside the vocabulary
|
||||
// skips this row and nothing else.
|
||||
const parsedContext =
|
||||
entry.context === undefined
|
||||
? { success: true as const, data: undefined }
|
||||
: moodContextSchema.nullable().safeParse(entry.context);
|
||||
if (!parsedContext.success) {
|
||||
const reason = "invalid_context";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({
|
||||
index: i,
|
||||
status: "skipped",
|
||||
reason,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// The five dimensions, parsed for THIS entry. Out of range skips this row
|
||||
// and nothing else.
|
||||
const parsedDimensions = bulkDimensionsSchema.safeParse({
|
||||
a1: entry.a1,
|
||||
a2: entry.a2,
|
||||
a3: entry.a3,
|
||||
a4: entry.a4,
|
||||
a5: entry.a5,
|
||||
});
|
||||
if (!parsedDimensions.success) {
|
||||
const reason = "invalid_dimensions";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({
|
||||
index: i,
|
||||
status: "skipped",
|
||||
reason,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const date = moodDateKey(entry.moodLoggedAt, tz);
|
||||
const score = getScoreForMood(entry.mood);
|
||||
// v1.37 — pleasantness derived from the same label the score comes from.
|
||||
// The batch request shape is unchanged: this path carries no level-A
|
||||
// input, so A2 to A5 are left alone rather than written as nulls, and a
|
||||
// row that already carries hand-set values keeps them across a re-import.
|
||||
const moodA1 = deriveA1(entry.mood);
|
||||
// v1.37 / v1.38 — the same resolution the single-entry path uses:
|
||||
// pleasantness derived from the label unless the entry states its own, and
|
||||
// the other four present only when this entry carried them. An omitted
|
||||
// dimension is left alone rather than written as null, so a row that
|
||||
// already carries hand-set values keeps them across a re-import.
|
||||
const levelA = levelAForWrite(entry.mood, parsedDimensions.data);
|
||||
|
||||
try {
|
||||
// v1.12.1 — when the entry carries a source-stable `externalId`,
|
||||
@@ -353,7 +445,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
tz,
|
||||
mood: entry.mood,
|
||||
score,
|
||||
moodA1,
|
||||
...levelA,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
@@ -370,7 +462,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// the same row.
|
||||
mood: entry.mood,
|
||||
score,
|
||||
moodA1,
|
||||
...levelA,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
@@ -392,6 +484,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// transaction that throw now rolls the mood row back with it, so a
|
||||
// "skipped" result means nothing was written — which is what the
|
||||
// client was already being told.
|
||||
// v1.38 — the day context, inside the same transaction as the row, so
|
||||
// an entry reported as skipped really did write nothing.
|
||||
await persistMoodContext(tx, upserted.id, user.id, parsedContext.data);
|
||||
|
||||
if (
|
||||
(entry.tagKeys && entry.tagKeys.length > 0) ||
|
||||
(entry.ratedFactors && entry.ratedFactors.length > 0)
|
||||
|
||||
+146
-106
@@ -25,6 +25,7 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a";
|
||||
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
@@ -114,6 +115,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
moodTag: { select: { key: true, kind: true } },
|
||||
},
|
||||
},
|
||||
// v1.38 — the day context, so the edit dialog can pre-populate the
|
||||
// sections without a second round trip per row. Sparse by design: most
|
||||
// rows carry none and the join costs nothing on those.
|
||||
context: true,
|
||||
},
|
||||
}),
|
||||
prisma.moodEntry.count({ where }),
|
||||
@@ -124,7 +129,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
// v1.37 — the five level-A values under the keys the create path takes,
|
||||
// so the edit form reads back what it wrote instead of mapping column
|
||||
@@ -145,6 +150,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating as number,
|
||||
})),
|
||||
// v1.38 — the day context, or null. The ciphertext never leaves the
|
||||
// server: `contextForWire` reads the note and drops the column.
|
||||
context: context ? contextForWire(context) : null,
|
||||
}));
|
||||
|
||||
return apiSuccess({
|
||||
@@ -207,6 +215,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
tagKeys,
|
||||
ratedFactors,
|
||||
note,
|
||||
context,
|
||||
moodLoggedAt,
|
||||
source,
|
||||
externalId,
|
||||
@@ -257,118 +266,141 @@ async function postMoodEntry(request: NextRequest) {
|
||||
// tag-link failure must roll the entry back too — otherwise a client
|
||||
// retry on the 5xx mints a duplicate entry. The tx client is threaded
|
||||
// through the helper so both writes commit (or abort) together.
|
||||
const { entry, persistedTagKeys, persistedRatedFactors } =
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// v1.12.1 — when the client supplies a source-stable `externalId`,
|
||||
// upsert on the NULL-distinct `(userId, source, externalId)` key so
|
||||
// a re-post with the same id updates the existing row in place
|
||||
// (idempotent re-import) instead of minting a duplicate or 409-ing.
|
||||
// Without an `externalId`, fall back to the legacy first-write
|
||||
// `create` exactly as before — a same-tuple re-post then trips the
|
||||
// `(userId, date, moodLoggedAt)` unique and surfaces as a 409 below.
|
||||
const created = externalId
|
||||
? await tx.moodEntry.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId: user.id,
|
||||
source: resolvedSource,
|
||||
externalId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
const {
|
||||
entry,
|
||||
persistedTagKeys,
|
||||
persistedRatedFactors,
|
||||
persistedContext,
|
||||
contextOutcome,
|
||||
} = await prisma.$transaction(async (tx) => {
|
||||
// v1.12.1 — when the client supplies a source-stable `externalId`,
|
||||
// upsert on the NULL-distinct `(userId, source, externalId)` key so
|
||||
// a re-post with the same id updates the existing row in place
|
||||
// (idempotent re-import) instead of minting a duplicate or 409-ing.
|
||||
// Without an `externalId`, fall back to the legacy first-write
|
||||
// `create` exactly as before — a same-tuple re-post then trips the
|
||||
// `(userId, date, moodLoggedAt)` unique and surfaces as a 409 below.
|
||||
const created = externalId
|
||||
? await tx.moodEntry.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
source: resolvedSource,
|
||||
externalId,
|
||||
moodLoggedAt,
|
||||
},
|
||||
// A re-post restates pleasantness, because the label it carries
|
||||
// always implies one and a stale A1 beside a changed mood would
|
||||
// contradict the entry's own face. It does NOT restate the other
|
||||
// four: `levelA` carries them only when this request did, so a
|
||||
// client re-posting without sliders — the phone, whose build has
|
||||
// none — leaves the stress and energy somebody answered on the
|
||||
// web exactly where they were. An explicit null clears one.
|
||||
update: {
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
moodLoggedAt,
|
||||
},
|
||||
})
|
||||
: await tx.moodEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
// A fresh row: an omitted dimension lands as NULL, which is
|
||||
// the same thing the upsert's arms mean by omitting it.
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
source: resolvedSource,
|
||||
moodLoggedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
source: resolvedSource,
|
||||
externalId,
|
||||
moodLoggedAt,
|
||||
},
|
||||
// A re-post restates pleasantness, because the label it carries
|
||||
// always implies one and a stale A1 beside a changed mood would
|
||||
// contradict the entry's own face. It does NOT restate the other
|
||||
// four: `levelA` carries them only when this request did, so a
|
||||
// client re-posting without sliders — the phone, whose build has
|
||||
// none — leaves the stress and energy somebody answered on the
|
||||
// web exactly where they were. An explicit null clears one.
|
||||
update: {
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
moodLoggedAt,
|
||||
},
|
||||
})
|
||||
: await tx.moodEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
// A fresh row: an omitted dimension lands as NULL, which is
|
||||
// the same thing the upsert's arms mean by omitting it.
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
source: resolvedSource,
|
||||
moodLoggedAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
(tagKeys && tagKeys.length > 0) ||
|
||||
(ratedFactors && ratedFactors.length > 0)
|
||||
) {
|
||||
// Unknown / non-RATED keys are dropped inside the helper (the
|
||||
// catalog is the source of truth). An out-of-scale rating
|
||||
// throws `RatedFactorOutOfRangeError`, rolling the tx back.
|
||||
await createTagLinks(
|
||||
created.id,
|
||||
user.id,
|
||||
tagKeys ?? [],
|
||||
tx,
|
||||
ratedFactors ?? [],
|
||||
);
|
||||
}
|
||||
if (
|
||||
(tagKeys && tagKeys.length > 0) ||
|
||||
(ratedFactors && ratedFactors.length > 0)
|
||||
) {
|
||||
// Unknown / non-RATED keys are dropped inside the helper (the
|
||||
// catalog is the source of truth). An out-of-scale rating
|
||||
// throws `RatedFactorOutOfRangeError`, rolling the tx back.
|
||||
await createTagLinks(
|
||||
created.id,
|
||||
user.id,
|
||||
tagKeys ?? [],
|
||||
tx,
|
||||
ratedFactors ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
// v1.8.5 / v1.12.0 — read the persisted links back so the create
|
||||
// response mirrors the list GET shape exactly: binary keys and
|
||||
// rated factors split by `kind` (unknown keys already filtered).
|
||||
const links = await tx.moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: created.id },
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true, kind: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
entry: created,
|
||||
persistedTagKeys: links
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => link.moodTag.key),
|
||||
persistedRatedFactors: links
|
||||
.filter(
|
||||
(link) => link.moodTag.kind === "RATED" && link.rating !== null,
|
||||
)
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating as number,
|
||||
})),
|
||||
};
|
||||
// v1.38 — the day context, in the SAME transaction as the entry and
|
||||
// its links, for the same reason they are: it is user-intended
|
||||
// content, not a cache, so a context write that fails has to roll the
|
||||
// entry back rather than leave a client retrying a 5xx into a
|
||||
// duplicate entry. An absent `context` key leaves a stored one alone;
|
||||
// a context that says nothing removes the row.
|
||||
const contextOutcome = await persistMoodContext(
|
||||
tx,
|
||||
created.id,
|
||||
user.id,
|
||||
context,
|
||||
);
|
||||
const storedContext = await tx.moodContext.findUnique({
|
||||
where: { moodEntryId: created.id },
|
||||
});
|
||||
|
||||
// v1.8.5 / v1.12.0 — read the persisted links back so the create
|
||||
// response mirrors the list GET shape exactly: binary keys and
|
||||
// rated factors split by `kind` (unknown keys already filtered).
|
||||
const links = await tx.moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: created.id },
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true, kind: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
entry: created,
|
||||
contextOutcome,
|
||||
persistedContext: storedContext,
|
||||
persistedTagKeys: links
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => link.moodTag.key),
|
||||
persistedRatedFactors: links
|
||||
.filter(
|
||||
(link) => link.moodTag.kind === "RATED" && link.rating !== null,
|
||||
)
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating as number,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
await auditLog("moodEntry.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -377,7 +409,11 @@ async function postMoodEntry(request: NextRequest) {
|
||||
|
||||
annotate({
|
||||
action: { name: "mood-entries.create" },
|
||||
meta: { moodEntryId: entry.id, mood },
|
||||
// `mood_context` says written / removed / untouched rather than a
|
||||
// boolean, because "the request said nothing" and "the request cleared
|
||||
// it" are different acts and a dashboard that folded them together
|
||||
// could not tell an abandoned section from a deliberate one.
|
||||
meta: { moodEntryId: entry.id, mood, mood_context: contextOutcome },
|
||||
});
|
||||
|
||||
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
|
||||
@@ -411,6 +447,10 @@ async function postMoodEntry(request: NextRequest) {
|
||||
tagKeys: persistedTagKeys,
|
||||
// v1.12.0 — rated factors with their per-entry score.
|
||||
ratedFactors: persistedRatedFactors,
|
||||
// v1.38 — the stored context, lists decoded and note decrypted. Null
|
||||
// when the entry carries none, which is a different answer from an
|
||||
// object full of nulls and reads as one.
|
||||
context: persistedContext ? contextForWire(persistedContext) : null,
|
||||
},
|
||||
201,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* `GET /api/mood/linked-context?date=YYYY-MM-DD&tz=…` — what the modules that
|
||||
* own sleep, activity, vitals and body symptoms already know about one day.
|
||||
*
|
||||
* Keyed by the local day rather than by an entry id, deliberately: the capture
|
||||
* sheet needs these figures BEFORE an entry exists, and a route that could
|
||||
* only answer for a saved row would leave the one surface that most wants them
|
||||
* unable to ask. The capture sheet sends the browser's zone, which is the zone
|
||||
* the day it is about to write will be anchored to; the edit dialog sends the
|
||||
* entry's stored `tz`, so an entry logged in another country keeps its own day
|
||||
* boundaries instead of being re-read under whichever zone the correction is
|
||||
* being made from.
|
||||
*
|
||||
* Nothing here is stored on the mood row. Every figure is read from its owning
|
||||
* module on each request, which is what makes a corrected sleep session show
|
||||
* up on the mood entry with nothing to re-sync.
|
||||
*
|
||||
* **This route declares `record`, not `mind`, and that is the whole security
|
||||
* decision on it.** Its payload is sleep minutes, steps, active energy,
|
||||
* resting heart rate, heart-rate variability and an illness day-log with its
|
||||
* symptom keys — the `measurements` and `illness` sections, reached from a
|
||||
* mood surface. Declared as `mind` it was a hole with a mood-shaped label on
|
||||
* it: a delegate given only the mind section could walk arbitrary dates
|
||||
* through this one URL and read two sections the owner had declined to share,
|
||||
* and no mood entry needed to exist for any of those dates. `record` is the
|
||||
* convention for a read that crosses sections (`/api/dashboard/snapshot`,
|
||||
* `/api/daily/digest` are the precedents) and a scoped grant never reaches
|
||||
* one, so a scoped delegate loses the linked block entirely rather than
|
||||
* receiving a filtered version of it. That is the honest answer: a block that
|
||||
* silently dropped the sections a grant does not cover would show a day that
|
||||
* looks empty where the truth is that it is none of the reader's business.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveLinkedDayContext } from "@/lib/mood/linked-context";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const querySchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
// The zone the day is anchored to. Absent falls back to the account's own
|
||||
// display timezone; the edit dialog passes the entry's stored `tz` so a row
|
||||
// logged elsewhere keeps its own day boundaries.
|
||||
tz: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// `record`: see the file header. A scoped grant is refused here by
|
||||
// construction rather than served a narrowed payload.
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "mood");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const parsed = querySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "mood.linked-context.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "mood.linkedContext.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const linked = await resolveLinkedDayContext(user.id, {
|
||||
date: parsed.data.date,
|
||||
tz: parsed.data.tz ?? user.timezone ?? DEFAULT_TIMEZONE,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.linked-context.read" },
|
||||
// Which blocks the person can see at all, so a support question about a
|
||||
// missing figure can be answered without asking them to describe their
|
||||
// module settings.
|
||||
meta: {
|
||||
day: linked.day,
|
||||
sleep_available: linked.sleep.available,
|
||||
activity_available: linked.activity.available,
|
||||
body_available: linked.body.available,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(linked);
|
||||
});
|
||||
@@ -54,6 +54,8 @@ import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { levelAForWire } from "@/lib/mood/level-a";
|
||||
import { contextForWire, type MoodContextWire } from "@/lib/mood/context";
|
||||
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
|
||||
import {
|
||||
decodeCursor,
|
||||
@@ -107,6 +109,25 @@ interface MoodUpsert {
|
||||
date: string;
|
||||
mood: string;
|
||||
score: number;
|
||||
/**
|
||||
* v1.38 — the five level-A self-state values, each 0-10 and each nullable.
|
||||
* `a2` is inverse-oriented: a higher number means more stress, and a client
|
||||
* that wants "up is better" flips the sign itself rather than the server
|
||||
* storing a flipped value.
|
||||
*
|
||||
* Server-authoritative: `a1` is derived from the five-point label on every
|
||||
* write, so a client that sends only `mood` still mirrors a complete row.
|
||||
*/
|
||||
a1: number | null;
|
||||
a2: number | null;
|
||||
a3: number | null;
|
||||
a4: number | null;
|
||||
a5: number | null;
|
||||
/**
|
||||
* v1.38 — the day's context, or null when the entry carries none. Null and
|
||||
* an object of nulls are different answers and the feed keeps them apart.
|
||||
*/
|
||||
context: MoodContextWire | null;
|
||||
tags: string | null;
|
||||
note: string | null;
|
||||
moodLoggedAt: string;
|
||||
@@ -299,6 +320,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
date: true,
|
||||
mood: true,
|
||||
score: true,
|
||||
moodA1: true,
|
||||
stressA2: true,
|
||||
energyA3: true,
|
||||
connectionA4: true,
|
||||
stabilityA5: true,
|
||||
context: true,
|
||||
tags: true,
|
||||
note: true,
|
||||
noteEncrypted: true,
|
||||
@@ -407,6 +434,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
date: row.date,
|
||||
mood: row.mood,
|
||||
score: row.score,
|
||||
// Under the same wire keys the write path accepts, so a client reads
|
||||
// back exactly what it can post.
|
||||
...levelAForWire(row),
|
||||
context: row.context ? contextForWire(row.context) : null,
|
||||
tags: row.tags,
|
||||
note: readNote(row.noteEncrypted, row.note),
|
||||
moodLoggedAt: row.moodLoggedAt.toISOString(),
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock("@/lib/db", () => ({
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
workoutSamples: {
|
||||
createMany: vi.fn(),
|
||||
createManyAndReturn: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
@@ -109,6 +109,23 @@ function makeRequest(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `workout.findMany` serves two callers in this route: the pre-flight
|
||||
* probe for rows that already exist (it passes a `where`), and — inside
|
||||
* the fake above — the stand-in for `INSERT ... RETURNING` (`select`
|
||||
* only). Keying on `select.id` alone stopped telling the two apart once
|
||||
* the probe began selecting `id` so an enrichment can address the stored
|
||||
* workout, so the RETURNING arm is the one WITHOUT a `where`.
|
||||
*/
|
||||
function mockInsertReturning(
|
||||
rows: Array<{ id: string; source: string; externalId: string }>,
|
||||
) {
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
where?: unknown;
|
||||
select?: Record<string, unknown>;
|
||||
}) => (args.where === undefined && args.select?.id ? rows : [])) as never);
|
||||
}
|
||||
|
||||
function validWorkout(
|
||||
externalId: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
@@ -161,7 +178,13 @@ beforeEach(() => {
|
||||
);
|
||||
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.workoutSamples.createMany).mockResolvedValue({ count: 0 });
|
||||
// The series write reports which rows landed, so an enrichment can tell
|
||||
// an attach from a row a concurrent writer got in first. The fake
|
||||
// echoes every row back as landed.
|
||||
vi.mocked(prisma.workoutSamples.createManyAndReturn).mockImplementation(
|
||||
(async (args: { data: Array<{ workoutId: string }> }) =>
|
||||
args.data.map((row) => ({ workoutId: row.workoutId }))) as never,
|
||||
);
|
||||
// v1.4.43 W9 — default to no per-user source-priority override so
|
||||
// the write-time picker walks the canonical default ladder. Tests
|
||||
// that exercise a custom ladder override this in-line.
|
||||
@@ -434,25 +457,18 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => {
|
||||
createdAt: new Date("2026-05-14T10:00:01.000Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: Record<string, unknown>;
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "apple-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "shared-external-id",
|
||||
},
|
||||
{
|
||||
id: "manual-id",
|
||||
source: "MANUAL",
|
||||
externalId: "shared-external-id",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
mockInsertReturning([
|
||||
{
|
||||
id: "apple-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "shared-external-id",
|
||||
},
|
||||
{
|
||||
id: "manual-id",
|
||||
source: "MANUAL",
|
||||
externalId: "shared-external-id",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -480,20 +496,13 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
// Post-insert lookup returns the newly-inserted workout id so the
|
||||
// route can be attached by FK.
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: { id?: true; source?: true; externalId?: true };
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "wkt-fresh-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-with-route",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
mockInsertReturning([
|
||||
{
|
||||
id: "wkt-fresh-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-with-route",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -523,20 +532,13 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
|
||||
describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
it("creates a WorkoutSamples row for an indoor workout (samples, no route)", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: { id?: true };
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "wkt-indoor-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-indoor",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
mockInsertReturning([
|
||||
{
|
||||
id: "wkt-indoor-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-indoor",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -556,8 +558,9 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
// The route table is untouched — an indoor workout has no GPS route.
|
||||
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
|
||||
// The HR series lands in the dedicated child table by FK.
|
||||
expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0];
|
||||
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
|
||||
.calls[0]?.[0];
|
||||
const rows = (
|
||||
call as {
|
||||
data: Array<{
|
||||
@@ -575,20 +578,13 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
|
||||
it("attaches both a route and an HR series on one workout", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: { id?: true };
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "wkt-both-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-both",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
mockInsertReturning([
|
||||
{
|
||||
id: "wkt-both-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-both",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -610,14 +606,15 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const routeRows = (
|
||||
vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as {
|
||||
data: Array<{ workoutId: string }>;
|
||||
}
|
||||
).data;
|
||||
const sampleRows = (
|
||||
vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0] as {
|
||||
vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
|
||||
.calls[0]?.[0] as {
|
||||
data: Array<{ workoutId: string }>;
|
||||
}
|
||||
).data;
|
||||
@@ -632,7 +629,7 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
makeRequest({ workouts: [validWorkout("uuid-no-series")] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutSamples.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.workoutSamples.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
* - `Content-Length` ceiling at 5 MB before parsing — anything
|
||||
* larger returns 413 so the iOS client falls back to one workout
|
||||
* per call.
|
||||
* - Per-entry status (`inserted | duplicate | skipped`) so the iOS
|
||||
* sync cursor can checkpoint accurately.
|
||||
* - Per-entry status (`inserted | duplicate | enriched | skipped`) so
|
||||
* the iOS sync cursor can checkpoint accurately.
|
||||
*
|
||||
* Idempotency contract:
|
||||
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
|
||||
@@ -39,6 +39,13 @@
|
||||
* composite index. Re-posting the same batch surfaces duplicates
|
||||
* rather than failing the call.
|
||||
*
|
||||
* Heart-rate series enrichment: a re-post that matches a stored
|
||||
* workout and carries a `samples` array attaches that series when the
|
||||
* workout has none. The workout row itself stays first-write-wins —
|
||||
* nothing on it is written, not one column — and a workout that already
|
||||
* holds a series is a no-op, so the client's history sweep is repeatable
|
||||
* as often as it likes. See the enrichment block below.
|
||||
*
|
||||
* Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through
|
||||
* Prisma's `createManyAndReturn`. The returned composite keys identify the
|
||||
* exact rows this request inserted, so per-entry statuses, child rows, and
|
||||
@@ -66,10 +73,11 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { MAX_WORKOUTS_PER_BATCH } from "@/lib/validations/workout";
|
||||
import {
|
||||
createBatchWorkoutSchema,
|
||||
MAX_WORKOUTS_PER_BATCH,
|
||||
} from "@/lib/validations/workout";
|
||||
INVALID_SAMPLES_REASON,
|
||||
parseWorkoutBatch,
|
||||
} from "@/lib/workouts/batch-ingest-parse";
|
||||
import {
|
||||
classifyExternalId,
|
||||
unstableExternalIdMeta,
|
||||
@@ -106,16 +114,14 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Per-entry outcome the iOS client uses to advance its sync cursor.
|
||||
* `inserted` and `duplicate` both indicate the row landed (or was
|
||||
* already present); the client can checkpoint past them identically.
|
||||
* `skipped` covers entries the server cannot durably store — currently
|
||||
* unused by this endpoint (the Zod schema rejects malformed entries
|
||||
* with a 422 before we reach the per-entry pass), but reserved on the
|
||||
* envelope so the response shape parallels the measurements batch and
|
||||
* future server-side validation can surface fine-grained skips without
|
||||
* breaking the iOS DTO.
|
||||
* `inserted`, `duplicate` and `enriched` all indicate the row landed (or
|
||||
* was already present); the client can checkpoint past them identically.
|
||||
* `enriched` additionally says the entry's heart-rate series was attached
|
||||
* to a workout that had none, which is what lets a history sweep count
|
||||
* its own progress. `skipped` covers entries the server refused: an
|
||||
* unusable external id, or a `samples` array that failed validation.
|
||||
*/
|
||||
type EntryStatus = "inserted" | "duplicate" | "skipped";
|
||||
type EntryStatus = "inserted" | "duplicate" | "enriched" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
@@ -251,15 +257,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = createBatchWorkoutSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
return apiError(issue?.message ?? "Invalid batch", 400, {
|
||||
// Strict on every field, per-entry on `samples` alone — see
|
||||
// `parseWorkoutBatch` for why the series is the one field whose
|
||||
// failure must not take the rest of a history sweep down with it.
|
||||
const parsed = parseWorkoutBatch(rawBody);
|
||||
if (!parsed.ok) {
|
||||
return apiError(parsed.message, 400, {
|
||||
errorCode: "workout.batch.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { workouts } = parsed.data;
|
||||
const refusedSampleIndices = new Set(parsed.refusedSampleIndices);
|
||||
|
||||
// Pre-flight duplicate detection so we can return per-entry status.
|
||||
// The composite unique index is `(userId, source, externalId)` —
|
||||
@@ -289,6 +298,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const results: EntryResult[] = new Array(workouts.length);
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
const prepared: Prepared[] = workouts.flatMap((w, index) => {
|
||||
// An entry whose series failed validation is refused whole. Storing
|
||||
// the workout without the curve the caller asked us to keep would be
|
||||
// a silent loss, and the row may well already exist anyway.
|
||||
if (refusedSampleIndices.has(index)) {
|
||||
results[index] = {
|
||||
index,
|
||||
status: "skipped",
|
||||
reason: INVALID_SAMPLES_REASON,
|
||||
};
|
||||
return [];
|
||||
}
|
||||
|
||||
// The `(userId, source, externalId)` dedup key is only idempotent
|
||||
// while the id is STABLE across client launches. An id that rotates
|
||||
// per process (an object description carrying a memory address)
|
||||
@@ -408,6 +429,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const survivors = prepared.filter((p) => survivingIndices.has(p.index));
|
||||
let duplicateCount = droppedByWriteDedup.length;
|
||||
let insertedCount = 0;
|
||||
let enrichedCount = 0;
|
||||
const insertedIdByIndex = new Map<number, string>();
|
||||
|
||||
if (survivors.length > 0) {
|
||||
@@ -418,6 +440,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
.map((p) => p.dedupKey)
|
||||
.filter((k): k is { source: string; externalId: string } => k !== null);
|
||||
|
||||
// The same probe answers both questions: does the row exist, and
|
||||
// does it already carry a series. `id` comes along so an enrichment
|
||||
// can address the stored workout without a second round trip.
|
||||
const existing = dedupCandidates.length
|
||||
? await prisma.workout.findMany({
|
||||
where: {
|
||||
@@ -427,30 +452,65 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
externalId: k.externalId,
|
||||
})),
|
||||
},
|
||||
select: { source: true, externalId: true },
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
samples: { select: { workoutId: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const existingSet = new Set(
|
||||
existing.map((row) => `${row.source}::${row.externalId}`),
|
||||
);
|
||||
const existingByKey = new Map<string, { id: string; hasSeries: boolean }>();
|
||||
for (const row of existing) {
|
||||
if (row.externalId === null) continue;
|
||||
existingByKey.set(`${row.source}::${row.externalId}`, {
|
||||
id: row.id,
|
||||
hasSeries: row.samples !== null,
|
||||
});
|
||||
}
|
||||
|
||||
const toInsert: Prepared[] = [];
|
||||
/** Stored workouts this batch hands a heart-rate series to. */
|
||||
const toEnrich: Array<{
|
||||
index: number;
|
||||
workoutId: string;
|
||||
samples: NonNullable<Prepared["samples"]>;
|
||||
}> = [];
|
||||
/** Workouts already claimed by an earlier entry of THIS batch. */
|
||||
const enrichClaimed = new Set<string>();
|
||||
for (const p of survivors) {
|
||||
const keyTuple = p.dedupKey
|
||||
? `${p.dedupKey.source}::${p.dedupKey.externalId}`
|
||||
: null;
|
||||
if (keyTuple !== null && existingSet.has(keyTuple)) {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
const known = keyTuple !== null ? existingByKey.get(keyTuple) : undefined;
|
||||
if (known) {
|
||||
if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) {
|
||||
enrichClaimed.add(known.id);
|
||||
// Enrichment. The status is assigned after the write, because
|
||||
// a concurrent writer may get the series in first — in which
|
||||
// case this entry is the plain duplicate it always was. A
|
||||
// second entry of this batch naming the same workout takes the
|
||||
// duplicate arm above: one workout, one series, one `enriched`.
|
||||
toEnrich.push({
|
||||
index: p.index,
|
||||
workoutId: known.id,
|
||||
samples: p.samples,
|
||||
});
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
}
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "inserted" };
|
||||
toInsert.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
if (toInsert.length > 0 || toEnrich.length > 0) {
|
||||
const CHUNK = 100;
|
||||
/** Workout ids whose series row this request actually wrote. */
|
||||
const seriesLanded = new Set<string>();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const withExternalId = toInsert.filter((p) => p.dedupKey !== null);
|
||||
@@ -517,6 +577,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// Enrichment rides the SAME child write as a fresh workout's
|
||||
// series — one mechanism, one place where a series is stored.
|
||||
// The only difference is where the `workoutId` came from: the
|
||||
// pre-flight probe instead of this request's INSERT ... RETURNING.
|
||||
// Nothing here writes to the workout row.
|
||||
for (const e of toEnrich) {
|
||||
samplesToInsert.push({
|
||||
workoutId: e.workoutId,
|
||||
samples: e.samples.samples as Prisma.InputJsonValue,
|
||||
sampleCount: e.samples.sampleCount,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < routesToInsert.length; i += CHUNK) {
|
||||
await tx.workoutRoute.createMany({
|
||||
data: routesToInsert.slice(i, i + CHUNK),
|
||||
@@ -524,10 +597,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < samplesToInsert.length; i += CHUNK) {
|
||||
await tx.workoutSamples.createMany({
|
||||
// RETURNING names the rows that actually landed. `workoutId` is
|
||||
// unique, so a workout that gained a series between the probe
|
||||
// and this write is skipped here and reports as a duplicate —
|
||||
// the re-post stays a no-op and never overwrites a stored curve.
|
||||
const landed = await tx.workoutSamples.createManyAndReturn({
|
||||
data: samplesToInsert.slice(i, i + CHUNK),
|
||||
skipDuplicates: true,
|
||||
select: { workoutId: true },
|
||||
});
|
||||
for (const row of landed) seriesLanded.add(row.workoutId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -540,6 +619,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
duplicateCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// An enriched entry is still a duplicate of the workout row: the
|
||||
// top-level counters count rows, and no row was inserted for it.
|
||||
// The per-entry status carries what happened to the series.
|
||||
for (const e of toEnrich) {
|
||||
results[e.index] = {
|
||||
index: e.index,
|
||||
status: seriesLanded.has(e.workoutId) ? "enriched" : "duplicate",
|
||||
};
|
||||
if (seriesLanded.has(e.workoutId)) enrichedCount += 1;
|
||||
duplicateCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +645,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
processed: workouts.length,
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
enriched: enrichedCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
@@ -584,12 +676,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// Counts only. The series itself never reaches a wide event.
|
||||
annotate({
|
||||
action: { name: "workout.batch.ingest" },
|
||||
meta: {
|
||||
processed: workouts.length,
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
enriched: enrichedCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
@@ -598,11 +692,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// caches when at least one row landed. Workouts ride on the
|
||||
// measurements bucket because achievements / analytics also touch
|
||||
// workout-derived metrics.
|
||||
if (insertedCount > 0) {
|
||||
// An enrichment changes what the workout reads as — the detail seam
|
||||
// gains its curve, the list gains its glyph — so it busts the cache
|
||||
// too, even though no workout row was written.
|
||||
if (insertedCount > 0 || enrichedCount > 0) {
|
||||
invalidateUserMeasurements(user.id);
|
||||
}
|
||||
|
||||
if (insertedCount > 0) {
|
||||
// One arrival per exact INSERT ... RETURNING winner. Historical rows still
|
||||
// stop at the shared salience classifier before any queue work.
|
||||
// An enrichment raises none: the workout arrived when it was stored.
|
||||
void emitWorkoutArrivals(user.id, prepared, insertedIdByIndex).catch(
|
||||
() => {},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user