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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 07:46:14 +02:00
1 parent 93495af788
commit 8f263e4767
6 files changed
+751 -9

No files matched your search

+406
View File
@@ -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:
+70 -8
View File
@@ -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<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;
}
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<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 +423,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 +445,15 @@ 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)
+31
View File
@@ -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(),
+111
View File
@@ -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."),
+24 -1
View File
@@ -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 }),
+109
View File
@@ -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(