From 8f263e4767b3567a056ceab94e4a1ac279acb5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 07:46:14 +0200 Subject: [PATCH] feat(api): publish the mood dimensions and the day context on the wire - /api/sync/changes carries a1 to a5 and the day context under the same keys the write path accepts, so a client reads back exactly what it can post - BulkMoodEntry accepts both, all optional; a1 stays server-derived from the five-point label, so a build that sends only a label still writes a complete row and needs no change to stay correct - the batch parses each entry's context on its own: one row carrying a value the vocabulary does not name is skipped and the rest of the batch lands - docs/api/openapi.yaml regenerated alongside --- docs/api/openapi.yaml | 406 +++++++++++++++++++++++++ src/app/api/mood-entries/bulk/route.ts | 78 ++++- src/app/api/sync/changes/route.ts | 31 ++ src/lib/openapi/routes/mood.ts | 111 +++++++ src/lib/openapi/routes/sync.ts | 25 +- tests/integration/mood-context.test.ts | 109 +++++++ 6 files changed, 751 insertions(+), 9 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3f6e6f2cf..026452bff 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15452,6 +15452,49 @@ components: note: type: string maxLength: 500 + a1: + description: "Pleasantness, 0-10. Optional: the server DERIVES it from `mood` when the entry does not carry one, so an + older build that sends only the five-point label still writes a complete row. A value sent here wins over + the derivation." + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a2: + description: 'Stress, 0-10, INVERSE-oriented: a higher value is more stress. Stored exactly as set; a client that wants + "up is better" flips the sign itself.' + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a3: + description: Energy, 0-10. + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a4: + description: Connectedness, 0-10. + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a5: + description: Stability, 0-10. + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + context: + description: The day's context. Absent leaves a stored context untouched on a re-post; an empty one removes it. + anyOf: + - $ref: "#/components/schemas/MoodContext" + - type: "null" moodLoggedAt: type: string format: date-time @@ -15478,6 +15521,163 @@ components: - mood - moodLoggedAt description: One bulk mood entry (UPSERT keyed by `externalId`). + MoodContext: + type: object + properties: + workStatus: + description: Shape of the working day. + anyOf: + - type: string + enum: + - off + - partial + - regular + - overtime + - type: "null" + workMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + overtimeMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + workLoad: + description: "0-10, INVERSE-oriented: a higher value is a heavier day, not a better one." + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + workSatisfaction: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactCircles: + description: "Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad + number." + anyOf: + - type: array + items: + type: string + enum: + - partner + - child + - family + - friends + - colleagues + - acquaintances + - professional + - other + - type: "null" + contactForm: + anyOf: + - type: string + enum: + - inPerson + - phone + - video + - written + - type: "null" + contactExtent: + anyOf: + - type: string + enum: + - brief + - medium + - extended + - type: "null" + contactQuality: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactSupport: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureCategories: + anyOf: + - type: array + items: + type: string + enum: + - film + - reading + - gaming + - music + - creative + - outing + - garden + - cooking + - timeWithChild + - relaxation + - other + - type: "null" + leisureMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + leisureJoy: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureRecovery: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + eventType: + description: One notable event. One per entry, not a list. + anyOf: + - type: string + enum: + - goodNews + - badNews + - success + - disappointment + - conflict + - appointment + - familyEvent + - change + - unexpected + - type: "null" + eventValence: + description: -5 (clearly bad) to +5 (clearly good). + anyOf: + - type: integer + minimum: -5 + maximum: 5 + - type: "null" + eventAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + note: + description: One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire. + anyOf: + - type: string + maxLength: 500 + - type: "null" + description: "The day around a mood entry: work, contacts, leisure and one notable event. Every field optional. Sleep, + activity, vitals and body symptoms are deliberately ABSENT — they are owned by their own modules and are read + from there, never captured a second time here." MoodTagLayoutPutRequest: type: object properties: @@ -19456,6 +19656,48 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + description: The legacy 1-5 axis. Unchanged. + a1: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Pleasantness, 0-10. Server-derived from `mood` on every write, so this is populated even for rows a client + posted without it. Consume the value; do not re-derive it. + a2: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: "Stress, 0-10, INVERSE-oriented: a higher value is more stress. NULL when nobody answered it." + a3: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Energy, 0-10, or null. + a4: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Connectedness, 0-10, or null. + a5: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Stability, 0-10, or null. + context: + anyOf: + - $ref: "#/components/schemas/MoodContextOutput" + - type: "null" + description: The day's context, or null when the entry carries none. Null and an object of nulls are different answers. tags: anyOf: - type: string @@ -19485,6 +19727,12 @@ components: - date - mood - score + - a1 + - a2 + - a3 + - a4 + - a5 + - context - tags - note - moodLoggedAt @@ -38191,6 +38439,164 @@ components: description: The account this session is now acting on, or null when it is back in its own. The stamp is a selector and not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one rather than at the next login. + MoodContextOutput: + type: object + properties: + workStatus: + description: Shape of the working day. + anyOf: + - type: string + enum: + - off + - partial + - regular + - overtime + - type: "null" + workMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + overtimeMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + workLoad: + description: "0-10, INVERSE-oriented: a higher value is a heavier day, not a better one." + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + workSatisfaction: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactCircles: + description: "Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad + number." + anyOf: + - type: array + items: + type: string + enum: + - partner + - child + - family + - friends + - colleagues + - acquaintances + - professional + - other + - type: "null" + contactForm: + anyOf: + - type: string + enum: + - inPerson + - phone + - video + - written + - type: "null" + contactExtent: + anyOf: + - type: string + enum: + - brief + - medium + - extended + - type: "null" + contactQuality: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactSupport: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureCategories: + anyOf: + - type: array + items: + type: string + enum: + - film + - reading + - gaming + - music + - creative + - outing + - garden + - cooking + - timeWithChild + - relaxation + - other + - type: "null" + leisureMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + leisureJoy: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureRecovery: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + eventType: + description: One notable event. One per entry, not a list. + anyOf: + - type: string + enum: + - goodNews + - badNews + - success + - disappointment + - conflict + - appointment + - familyEvent + - change + - unexpected + - type: "null" + eventValence: + description: -5 (clearly bad) to +5 (clearly good). + anyOf: + - type: integer + minimum: -5 + maximum: 5 + - type: "null" + eventAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + note: + description: One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire. + anyOf: + - type: string + maxLength: 500 + - type: "null" + additionalProperties: false + description: "The day around a mood entry: work, contacts, leisure and one notable event. Every field optional. Sleep, + activity, vitals and body symptoms are deliberately ABSENT — they are owned by their own modules and are read + from there, never captured a second time here." MedicationScheduleOutput: type: object properties: diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 938be2a60..86e56ebca 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -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,32 @@ 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. A value out of range marks THIS entry skipped and never + * the batch, which is what the per-entry parse below is for. + */ + 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(), + /** + * 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"), /** @@ -275,13 +303,38 @@ async function postBulk(request: NextRequest): Promise { 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; + } + 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, { + a1: entry.a1, + a2: entry.a2, + a3: entry.a3, + a4: entry.a4, + a5: entry.a5, + }); try { // v1.12.1 — when the entry carries a source-stable `externalId`, @@ -353,7 +406,7 @@ async function postBulk(request: NextRequest): Promise { tz, mood: entry.mood, score, - moodA1, + ...levelA, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), @@ -370,7 +423,7 @@ async function postBulk(request: NextRequest): Promise { // 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 +445,15 @@ async function postBulk(request: NextRequest): Promise { // 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) diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 750ea47f5..3688a6863 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -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(), diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index c6ced7253..1ab95c051 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -24,6 +24,14 @@ import { } from "@/lib/mood/custom-tags"; import { moodTagLayoutSchema } from "@/lib/mood/tag-layout"; import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood"; +import { + CONTACT_CIRCLE_KEYS, + CONTACT_EXTENT_KEYS, + CONTACT_FORM_KEYS, + EVENT_TYPE_KEYS, + LEISURE_CATEGORY_KEYS, + WORK_STATUS_KEYS, +} from "@/lib/mood/context-vocabulary"; import { dataEnvelope, errorEnvelope, @@ -188,6 +196,77 @@ const notFoundResponse = { }, } as const; +/** + * v1.38 — the day's context on a mood entry, in the one shape both the write + * path and the sync feed use. + * + * Every field optional and nullable. The two multi-selects are closed key + * lists; a value outside them is refused rather than stored, which is what + * keeps the analytics and the six locale label sets from drifting apart from + * the data. + * + * Exported so `./sync.ts` publishes the same object rather than declaring a + * second one — two definitions of one payload is how a client ends up decoding + * a field the server stopped sending. + */ +export const moodContextWire = z + .object({ + workStatus: z + .enum(WORK_STATUS_KEYS) + .nullish() + .describe("Shape of the working day."), + workMinutes: z.number().int().min(0).max(1440).nullish(), + overtimeMinutes: z.number().int().min(0).max(1440).nullish(), + workLoad: z + .number() + .int() + .min(0) + .max(10) + .nullish() + .describe( + "0-10, INVERSE-oriented: a higher value is a heavier day, not a better one.", + ), + workSatisfaction: z.number().int().min(0).max(10).nullish(), + contactCircles: z + .array(z.enum(CONTACT_CIRCLE_KEYS)) + .nullish() + .describe( + "Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad number.", + ), + contactForm: z.enum(CONTACT_FORM_KEYS).nullish(), + contactExtent: z.enum(CONTACT_EXTENT_KEYS).nullish(), + contactQuality: z.number().int().min(0).max(10).nullish(), + contactSupport: z.number().int().min(0).max(10).nullish(), + leisureCategories: z.array(z.enum(LEISURE_CATEGORY_KEYS)).nullish(), + leisureMinutes: z.number().int().min(0).max(1440).nullish(), + leisureJoy: z.number().int().min(0).max(10).nullish(), + leisureRecovery: z.number().int().min(0).max(10).nullish(), + eventType: z + .enum(EVENT_TYPE_KEYS) + .nullish() + .describe("One notable event. One per entry, not a list."), + eventValence: z + .number() + .int() + .min(-5) + .max(5) + .nullish() + .describe("-5 (clearly bad) to +5 (clearly good)."), + eventAt: z.iso.datetime({ offset: true }).nullish(), + note: z + .string() + .max(500) + .nullish() + .describe( + "One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire.", + ), + }) + .meta({ + id: "MoodContext", + description: + "The day around a mood entry: work, contacts, leisure and one notable event. Every field optional. Sleep, activity, vitals and body symptoms are deliberately ABSENT — they are owned by their own modules and are read from there, never captured a second time here.", + }); + // ── Bulk mood backfill (iOS SyncMode) — mirrors the route's // `bulkPayloadSchema` / `bulkEntrySchema` and the batch-envelope response. const bulkMoodEntry = z @@ -212,6 +291,38 @@ const bulkMoodEntry = z "Rated mood factors; an out-of-scale rating marks THIS entry skipped, never the batch.", ), note: z.string().max(500).optional(), + a1: z + .number() + .int() + .min(0) + .max(10) + .nullish() + .describe( + "Pleasantness, 0-10. Optional: the server DERIVES it from `mood` when the entry does not carry one, so an older build that sends only the five-point label still writes a complete row. A value sent here wins over the derivation.", + ), + a2: z + .number() + .int() + .min(0) + .max(10) + .nullish() + .describe( + 'Stress, 0-10, INVERSE-oriented: a higher value is more stress. Stored exactly as set; a client that wants "up is better" flips the sign itself.', + ), + a3: z.number().int().min(0).max(10).nullish().describe("Energy, 0-10."), + a4: z + .number() + .int() + .min(0) + .max(10) + .nullish() + .describe("Connectedness, 0-10."), + a5: z.number().int().min(0).max(10).nullish().describe("Stability, 0-10."), + context: moodContextWire + .nullish() + .describe( + "The day's context. Absent leaves a stored context untouched on a re-post; an empty one removes it.", + ), moodLoggedAt: z.iso .datetime({ offset: true }) .describe("ISO instant the entry was logged."), diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts index a28d266b9..2f767327b 100644 --- a/src/lib/openapi/routes/sync.ts +++ b/src/lib/openapi/routes/sync.ts @@ -8,6 +8,7 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { measurementResource } from "./measurements"; +import { moodContextWire } from "./mood"; import { dataEnvelope, stdResponses } from "./shared"; // ── Sync (v1.7.0 offline / server-optional) ───────────────────────── @@ -97,7 +98,29 @@ const syncMoodUpsert = z id: z.string(), date: z.string().describe("YYYY-MM-DD anchored to the row's `tz`."), mood: z.string(), - score: z.number().int(), + score: z.number().int().describe("The legacy 1-5 axis. Unchanged."), + a1: z + .number() + .int() + .nullable() + .describe( + "Pleasantness, 0-10. Server-derived from `mood` on every write, so this is populated even for rows a client posted without it. Consume the value; do not re-derive it.", + ), + a2: z + .number() + .int() + .nullable() + .describe( + "Stress, 0-10, INVERSE-oriented: a higher value is more stress. NULL when nobody answered it.", + ), + a3: z.number().int().nullable().describe("Energy, 0-10, or null."), + a4: z.number().int().nullable().describe("Connectedness, 0-10, or null."), + a5: z.number().int().nullable().describe("Stability, 0-10, or null."), + context: moodContextWire + .nullable() + .describe( + "The day's context, or null when the entry carries none. Null and an object of nulls are different answers.", + ), tags: z.string().nullable().describe("JSON array of tag keys, or null."), note: z.string().nullable(), moodLoggedAt: z.iso.datetime({ offset: true }), diff --git a/tests/integration/mood-context.test.ts b/tests/integration/mood-context.test.ts index 3b97bcede..a05ea41d1 100644 --- a/tests/integration/mood-context.test.ts +++ b/tests/integration/mood-context.test.ts @@ -337,6 +337,115 @@ describe("mood context write path (real Postgres)", () => { expect(await getPrismaClient().moodEntry.count()).toBe(0); }); + it("the bulk path stores a context and skips only the entry that is wrong", async () => { + // The contract this endpoint states in its own description: a bad row is + // skipped and the batch lands. A phone draining a year of local history + // must not lose the year to one row carrying a retired key. + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + postRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-23T08:00:00.000Z", + externalId: "ios-bulk-1", + a2: 6, + context: { workStatus: "partial", workMinutes: 240 }, + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-23T12:00:00.000Z", + externalId: "ios-bulk-2", + context: { workStatus: "sabbatical" }, + }, + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-23T18:00:00.000Z", + externalId: "ios-bulk-3", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + inserted: number; + skipped: Array<{ index: number; reason: string }>; + }; + }; + expect(body.data.inserted).toBe(2); + expect(body.data.skipped).toEqual([ + { index: 1, reason: "invalid_context" }, + ]); + + const entries = await getPrismaClient().moodEntry.findMany({ + orderBy: { moodLoggedAt: "asc" }, + include: { context: true }, + }); + expect(entries).toHaveLength(2); + // The good row landed whole: the level-A value it sent, the derived one it + // did not, and its context. + expect(entries[0].stressA2).toBe(6); + expect(entries[0].moodA1).toBe(7); + expect(entries[0].context?.workStatus).toBe("partial"); + expect(entries[0].context?.workMinutes).toBe(240); + // The row that carried no context has none, rather than an empty one. + expect(entries[1].context).toBeNull(); + expect(entries[1].moodA1).toBe(9); + }); + + it("the sync feed carries both levels under the keys the write path takes", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + await POST( + postRequest("/api/mood-entries", { + mood: "SCHLECHT", + moodLoggedAt: "2026-05-24T08:00:00.000Z", + a2: 8, + context: { + eventType: "badNews", + eventValence: -4, + note: "Called in the afternoon.", + }, + }), + ); + + const { GET } = await import("@/app/api/sync/changes/route"); + const res = await GET(new NextRequest("http://localhost/api/sync/changes")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + changes: { + mood: { + upserts: Array<{ + a1: number | null; + a2: number | null; + a5: number | null; + context: { + eventType: string | null; + eventValence: number | null; + note: string | null; + contactCircles: string[]; + } | null; + }>; + }; + }; + }; + }; + const upsert = body.data.changes.mood.upserts[0]; + // Derived server-side, so a client that sent only a label still mirrors a + // complete row. + expect(upsert.a1).toBe(3); + expect(upsert.a2).toBe(8); + // Untouched dimensions stay absent rather than arriving as a middle value. + expect(upsert.a5).toBeNull(); + expect(upsert.context?.eventType).toBe("badNews"); + expect(upsert.context?.eventValence).toBe(-4); + // The note is decrypted for the client; the ciphertext never rides. + expect(upsert.context?.note).toBe("Called in the afternoon."); + expect(upsert.context?.contactCircles).toEqual([]); + expect(JSON.stringify(upsert)).not.toContain("notesEncrypted"); + }); + it("deleting the entry takes its context with it", async () => { const { POST } = await import("@/app/api/mood-entries/route"); const created = await POST(