mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
93495af788
commit
8f263e4767
6 files changed
+751
-9
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user