Files
HealthLog/src/lib/openapi/routes/mood.ts
T
Marc-André Bombeck aecf6f91b1 fix(api): publish what the GET handlers send
A response parsed against its own registered schema disagreed in thirteen
operations. Where the handler was right, the schema now says so; where the
handler had drifted from its siblings, it follows them.

Schema:
- measurements list and detail declare aggregationProvenance
- series-batch and sleep night stages are partial records: only the
  requested types and the recorded stages are present
- medications declare deliveryForm, the injection-site fields, externalId,
  externalSource, the low-stock bookkeeping, schedules[].doseWindows and
  nextDueScheduleId on the list
- notification prefs declare coach.nudgeAiComposed and measurementReminder
- mood insights correlations declare suppressed
- record settings profile.unitPreference is nullable, as stored
- insights/comprehensive describes the computed overview it returns rather
  than the model digest fields it never sends

Handler:
- GET /api/mood-entries/{id} shares the list projection and now carries
  tagKeys and ratedFactors
- GET and PATCH /api/custom-metrics/{id} carry latest and entryCount
- /api/analytics no longer leaks anomalyCount through the per-night sleep
  summary and the glucose meal-context split
2026-10-20 13:16:22 +02:00

1859 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OpenAPI route table — the mood surface: entry CRUD (list, create, single
* read/edit/delete, restore, bulk delete), the bulk backfill, the linked-day
* read, and mood-tag taxonomy management (catalog read, custom tags, custom
* groups, per-user layout, catalogue hide).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
* Request schemas come from `src/lib/validations/mood.ts` and
* `src/lib/mood/{custom-tags,tag-layout}.ts` where they are shared with the
* runtime request parsing, so the wire contract stays single-source. Response
* DTOs are declared here mirroring the route handlers under
* `src/app/api/mood-entries/` and `src/app/api/mood/`.
*
* v1.13.0 shipped the custom-tag CRUD + hide surface outside the YAML;
* v1.17.0 adds groups + layout and registers the WHOLE taxonomy surface so
* the iOS client gets a locked contract. The entry CRUD family itself and
* the linked-day read joined the published document later — they predated
* the contract surface and had been served unpublished while their bulk
* twin was in the YAML, which left grant-scope reviewers unable to see the
* cross-section read at all.
*/
import { moodPattern } from "./trend-verdict";
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
createCustomTagSchema,
updateCustomTagSchema,
createCustomGroupSchema,
updateCustomGroupSchema,
hideCatalogueTagSchema,
} from "@/lib/mood/custom-tags";
import { moodTagLayoutSchema } from "@/lib/mood/tag-layout";
import {
createMoodEntrySchema,
listMoodEntriesSchema,
moodLevelEnum,
moodSourceEnum,
updateMoodEntrySchema,
} 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 {
MODULE_DISABLED_DESCRIPTION,
baseUpdatedAtField,
conflictResponse409,
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
invalidBaseTokenResponse,
moduleDisabledResponse,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
updatedAtTokenField,
} from "./shared";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { isoDateTime } from "@/lib/validations/datetime";
// ── Request schemas — annotated for spec emission ────────────────────
//
// Assigned rather than annotated in place: Zod 4's `.meta()` returns a NEW
// instance carrying the id, so the route table below must reference these
// consts for the names to reach `components.schemas`.
const createMoodTagRequest = createCustomTagSchema.meta({
id: "CreateMoodTagRequest",
description:
"Create a per-user custom mood tag (BINARY only). `label` is encrypted at rest. `icon` must come from the curated allowlist (unknown name → 422). `categoryKey` picks the home group — any seeded category key or one of the caller's own `customcat:` group keys; omitted → the seeded `custom` category. Capped at 50 active custom tags per user (422).",
});
const updateMoodTagRequest = updateCustomTagSchema.meta({
id: "UpdateMoodTagRequest",
description:
"Partial custom-tag edit; at least one field required. `isActive:false` archives (history intact), `isActive:true` restores. `categoryKey` moves the tag to another group (a real categoryId move).",
});
const createMoodTagGroupRequest = createCustomGroupSchema.meta({
id: "CreateMoodTagGroupRequest",
description:
"Create a per-user custom mood-tag group. `label` is encrypted at rest; `icon` must come from the curated allowlist. Capped at 12 active custom groups per user (422).",
});
const updateMoodTagGroupRequest = updateCustomGroupSchema.meta({
id: "UpdateMoodTagGroupRequest",
description:
"Partial custom-group edit; at least one field required. `isActive:false` retires the group without touching its tags.",
});
const hideMoodTagRequest = hideCatalogueTagSchema.meta({
id: "HideMoodTagRequest",
description:
"Hide / show a CATALOGUE tag for the calling user. Custom tags are hidden via their own `isActive` flag on the custom PATCH instead (this route 400s a `custom:` key).",
});
const createMoodEntryRequest = createMoodEntrySchema.meta({
id: "CreateMoodEntryRequest",
description:
"Single mood-entry create. `mood` is the five-point label; the server derives `score` (1-5) and pleasantness `a1` from it, so a one-tap check-in writes a complete entry. `a1`-`a5` are the level-A self-state values (0-10): absent says nothing, a number is the answer, an explicit null takes an answer back (`a1` falls back to the derivation instead of clearing). With `externalId` the create UPSERTS on `(userId, source, externalId)` — the idempotent re-post iOS drives over Bearer; without it a same-`(date, moodLoggedAt)` re-post 409s. `moodLoggedAt` is bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900.",
});
const updateMoodEntryRequest = updateMoodEntrySchema.meta({
id: "UpdateMoodEntryRequest",
description:
"Per-field mood-entry edit. Omitted fields keep the stored value. `tagKeys`, `ratedFactors` and `context` replace their whole set when present (`null` clears). `a2`-`a5`: explicit null clears; `a1` null re-derives from the label. Editing `mood` without `a1` re-derives pleasantness the same way it re-derives `score`. Editing `moodLoggedAt` re-anchors the row's `date` (and `tz`) to the account's display timezone.",
});
const listMoodEntriesQuery = listMoodEntriesSchema.meta({
id: "ListMoodEntriesQuery",
description:
"Filters + paging for the mood-entry list. `from` / `to` bound the local-day `date` label (YYYY-MM-DD, inclusive). `limit` capped at 500.",
});
/**
* Exported for the forced registration in `./index.ts`.
*
* Both consumers are an `.extend()` of this base, and `.extend()` builds a new
* object rather than a reference, so the name never carries forward on its own
* even though the annotation is in the assigning form. Same shape as
* `CoachPrefs` and `Medication`, and the same remedy.
*/
export const moodTagLayout = moodTagLayoutSchema.meta({
id: "MoodTagLayout",
description:
"Per-user mood-tag presentation blob. `groupOrder`: category keys in display order (unknown dropped, missing appended in seeded order). `placements`: categoryKey → ordered tag keys; a placed tag renders in that group at that index, un-placed tags follow in their home category. Display-only — placements referencing hidden/archived/unknown keys are silently dropped at read time. Both fields optional: PUT merges preserve-when-absent.",
});
// ── Response DTOs ─────────────────────────────────────────────────────
const moodTagDto = z
.object({
key: z.string(),
labelKey: z.string().nullable(),
label: z.string().nullable(),
icon: z.string().nullable(),
kind: z.enum(["BINARY", "RATED"]),
scaleMin: z.number().int(),
scaleMax: z.number().int(),
inverse: z.boolean(),
custom: z.boolean(),
hidden: z
.boolean()
.optional()
.describe(
"Catalogue tags only, present when `include` contains `hidden`.",
),
archived: z
.boolean()
.optional()
.describe(
"Custom tags only, present when `include` contains `archived`.",
),
usageCount: z
.number()
.int()
.optional()
.describe(
"Live-entry link count for this user, present when `include` contains `usage`.",
),
})
.meta({
id: "MoodTagDTO",
description:
"One effective mood tag. Render `label` when `custom`, else resolve `labelKey` against the locale.",
});
const moodTagCategoryDto = z
.object({
key: z.string(),
labelKey: z.string().nullable(),
label: z.string().nullable(),
icon: z.string().nullable(),
custom: z.boolean(),
tags: z.array(moodTagDto),
})
.meta({
id: "MoodTagCategoryDTO",
description:
"One group of the effective tree, already per-user ordered (layout applied server-side). `custom: true` = the caller's own group (render `label`); seeded groups resolve `labelKey`.",
});
const moodTagTreeResponse = z.object({
categories: z.array(moodTagCategoryDto),
});
const moodCustomTagResponse = z.object({
key: z.string(),
labelKey: z.null(),
label: z.string().nullable(),
icon: z.string().nullable(),
kind: z.enum(["BINARY", "RATED"]),
scaleMin: z.number().int(),
scaleMax: z.number().int(),
inverse: z.boolean(),
custom: z.literal(true),
});
const moodCustomTagPatchResponse = moodCustomTagResponse.extend({
isActive: z.boolean(),
});
const moodTagGroupResponse = z.object({
key: z.string(),
labelKey: z.null(),
label: z.string().nullable(),
icon: z.string().nullable(),
custom: z.literal(true),
});
const moodTagGroupPatchResponse = moodTagGroupResponse.extend({
isActive: z.boolean(),
});
const moodTagLayoutResolved = z
.object({
groupOrder: z.array(z.string()),
placements: z.record(z.string(), z.array(z.string())),
// v1.32.22 — optimistic-concurrency token echoed on GET / PUT.
updatedAt: updatedAtTokenField,
})
.meta({
id: "MoodTagLayoutResolved",
description:
"The stored layout merged over defaults: `groupOrder` fully resolved against the user's effective category set, `placements` as stored. `updatedAt` is the optimistic-concurrency token — echo it as `baseUpdatedAt` on the next PUT.",
});
// v1.32.22 — the PUT request extends the layout schema with the base
// token (stripped pre-Zod at runtime).
const moodTagLayoutPutRequest = moodTagLayout
.extend({ baseUpdatedAt: baseUpdatedAtField })
.meta({
id: "MoodTagLayoutPutRequest",
description:
"Mood-tag layout to merge (preserve-when-absent), plus the optional optimistic-concurrency `baseUpdatedAt` token. The two manage cards write different fields of the same blob; echo the token to have a stale write refused with 409 instead of resurrecting the other card's overwritten field. Omit it for the legacy unconditional write.",
});
const notFoundResponse = {
"404": {
description: "Not found / not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
} 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: isoDateTime().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
.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
tagKeys: z
.array(z.string().max(60))
.max(30)
.optional()
.describe("Structured-tag keys from the catalog; unknown keys dropped."),
ratedFactors: z
.array(
z.object({
key: z.string().max(60),
rating: z.number().int().min(1).max(5),
}),
)
.max(30)
.optional()
.describe(
"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: isoDateTime().describe("ISO instant the entry was logged."),
source: moodSourceEnum.optional().describe("Defaults to MANUAL."),
externalId: z
.string()
.min(1)
.max(120)
.optional()
.describe(
"iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key. Must be stable across app restarts — an object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value on every launch; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands.",
),
})
.meta({
id: "BulkMoodEntry",
description: "One bulk mood entry (UPSERT keyed by `externalId`).",
});
const bulkMoodPayload = z
.object({
entries: z.array(bulkMoodEntry).min(1).max(500),
})
.meta({
id: "BulkMoodRequest",
description:
"iOS SyncMode bulk mood backfill. 1–500 entries per call; per-entry UPSERT keyed by `externalId` so re-runs are idempotent.",
});
// The submitted keys a write did not store. Shared by the bulk result and the
// single-entry write responses so the two surfaces name the same thing.
const droppedTagKeysField = z
.array(z.string())
.optional()
.describe(
"`tagKeys` from this request that were not stored: unknown to the catalog, archived, another account's custom tag, or a rated factor sent as a binary key. Present only when non-empty; absent means every submitted key landed. The entry itself was written.",
);
const droppedFactorKeysField = z
.array(z.string())
.optional()
.describe(
"`ratedFactors` keys from this request that were not stored: unknown, archived, or not a rated factor. Present only when non-empty; absent means every submitted factor landed.",
);
const bulkMoodEntryResult = z
.object({
index: z.number().int().nonnegative(),
status: z
.enum(["inserted", "duplicate", "skipped"])
.describe(
"`inserted`/`duplicate` — the row landed (advance the cursor). `skipped` — see `reason`.",
),
reason: z.string().optional(),
id: z
.string()
.optional()
.describe("The upserted row id (absent on skips)."),
externalId: z
.string()
.optional()
.describe("Echoed back when the entry carried one, for client mapping."),
droppedTagKeys: droppedTagKeysField,
droppedFactorKeys: droppedFactorKeysField,
})
.meta({ id: "BulkMoodEntryResult" });
const bulkMoodResponse = z
.object({
processed: z.number().int().nonnegative(),
inserted: z.number().int().nonnegative(),
duplicates: z.number().int().nonnegative(),
skipped: z.array(
z.object({
index: z.number().int().nonnegative(),
reason: z.string(),
}),
),
entries: z.array(bulkMoodEntryResult),
})
.meta({ id: "BulkMoodResponse" });
// ── Entry CRUD — mirrors the route handlers under
// `src/app/api/mood-entries/` and the shared list read
// (`src/lib/mood/list-read.ts`). One DTO for the list rows, the single GET,
// the create and the edit response, because the handlers deliberately answer
// one shape: the edit form reads back exactly what the write path takes.
const moodEntryRatedFactor = z
.object({
key: z.string(),
rating: z.number().int(),
})
.meta({
id: "MoodEntryRatedFactor",
description: "One rated factor on an entry, with its per-entry score.",
});
const moodEntryDto = z
.object({
id: z.string(),
userId: z.string(),
date: z
.string()
.describe(
`YYYY-MM-DD, anchored to the row's \`tz\` (legacy rows with \`tz: null\` read as ${DEFAULT_TIMEZONE}).`,
),
mood: moodLevelEnum,
score: z
.number()
.int()
.describe("The legacy 1-5 axis, server-derived from `mood`."),
a1: z
.number()
.int()
.nullable()
.describe(
"Pleasantness, 0-10. Server-derived from `mood` on every write when the request carried none, so it 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."),
tags: z
.array(z.string())
.describe("Free-text tag list, decoded from storage; `[]` when none."),
note: z
.string()
.nullable()
.describe(
"Free-text note, decrypted for the response. AES-256-GCM at rest; the ciphertext never rides the wire.",
),
source: moodSourceEnum,
externalId: z
.string()
.nullable()
.describe("The sync client's source-stable dedup id, or null."),
moodLoggedAt: isoDateTime(),
tz: z
.string()
.nullable()
.describe(
`IANA zone the \`date\` label is anchored to. Null on legacy rows (read as ${DEFAULT_TIMEZONE}).`,
),
syncedAt: isoDateTime(),
createdAt: isoDateTime(),
updatedAt: isoDateTime(),
syncVersion: z
.number()
.int()
.nonnegative()
.describe("LWW reconciliation counter; mood is last-writer-wins by it."),
deletedAt: z
.null()
.describe(
"Always null on this surface: tombstoned rows are hidden from the list and 404 on a direct GET. Tombstones surface on the `/api/sync/changes` feed instead.",
),
tagKeys: z
.array(z.string())
.describe("Binary structured-tag keys linked to the entry."),
ratedFactors: z.array(moodEntryRatedFactor),
context: moodContextWire
.nullable()
.describe(
"The day's context, or null when the entry carries none. Null and an object of nulls are different answers.",
),
droppedTagKeys: droppedTagKeysField.describe(
"Create and edit responses only: `tagKeys` from the request that are not on the entry (unknown, archived, another account's custom tag, or a rated factor sent as a binary key). Present only when non-empty. An archived tag the edit preserved is on the entry and is not listed.",
),
droppedFactorKeys: droppedFactorKeysField.describe(
"Create and edit responses only: `ratedFactors` keys from the request that are not on the entry. Present only when non-empty.",
),
})
.meta({
id: "MoodEntryDTO",
description:
"One mood entry as every entry surface answers it — list rows, single GET, create and edit responses share this shape, so a client hydrating from a write response renders without a refetch.",
});
const moodEntryEnvelope = dataEnvelope(moodEntryDto, "MoodEntryEnvelope");
const moodEntryListResponse = z.object({
entries: z.array(moodEntryDto),
meta: z.object({
total: z.number().int().nonnegative(),
limit: z.number().int(),
offset: z.number().int(),
}),
});
// Mirrors the route-local `restoreSchema` / `bulkDeleteSchema`
// (`src/app/api/mood-entries/{restore,bulk-delete}/route.ts`) — the two
// declare the identical shape on purpose, so one published schema serves both.
const moodEntryIdsPayload = z
.object({
ids: z.array(z.string().min(1)).min(1).max(200),
})
.meta({
id: "MoodEntryIdsRequest",
description:
"1-200 mood-entry ids, scoped to the resolved record. A forged / foreign / non-matching id is a silent no-op, never a 404 existence leak: the mutation `where` pins the record owner's id.",
});
// The `(userId, date, moodLoggedAt)` unique — only the legacy no-`externalId`
// create path and a `moodLoggedAt` edit can trip it; a create carrying
// `externalId` upserts instead of 409-ing.
const duplicateTimestampResponse = {
"409": {
description:
"A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place.",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
// ── The linked day — mirrors `src/lib/mood/linked-context.ts` ────────
const linkedFigure = z
.union([
z.object({ present: z.literal(false) }),
z.object({
present: z.literal(true),
value: z.number(),
unit: z.string(),
}),
])
.meta({
id: "MoodLinkedFigure",
description:
"A figure that either exists or honestly does not. `{ present: false }` means the data is not recorded — never zero, never an error.",
});
/**
* A block that can also be absent because its owning module is switched off.
* A disabled module blanks its block rather than answering zero or a filtered
* version of itself.
*/
function linkedBlock<T extends z.ZodRawShape>(shape: T) {
return z.union([
z.object({
available: z.literal(false),
reason: z.literal("module-disabled"),
}),
z.object({ available: z.literal(true), ...shape }),
]);
}
const moodLinkedContextResponse = z
.object({
day: z
.string()
.describe("The local day the figures are about, YYYY-MM-DD."),
sleep: linkedBlock({
asleep: linkedFigure.describe(
"Time asleep (minutes) for the night the day woke up on.",
),
inBed: linkedFigure.describe(
"Time in bed (minutes), when any writer recorded a bed window.",
),
}),
activity: linkedBlock({
steps: linkedFigure,
activeEnergy: linkedFigure,
}),
vitals: linkedBlock({
restingHeartRate: linkedFigure,
heartRateVariability: linkedFigure,
}),
body: linkedBlock({
logged: z
.boolean()
.describe("Whether an illness day-log exists for this day at all."),
functionalImpact: linkedFigure.describe(
"How much the day was limited, 0-3, as the illness module records it.",
),
symptoms: z
.array(z.string())
.describe("Symptoms linked to the day-log, by catalogue key."),
episodeId: z
.string()
.nullable()
.describe("The episode to open in the illness module, when any."),
}),
})
.meta({
id: "MoodLinkedContext",
description:
"What the modules that own sleep, activity, vitals and body symptoms already know about one local day. Nothing here is stored on the mood row — every figure is resolved from its owning module at read time, cross-source de-duplicated to one canonical source per metric. A switched-off module blanks its whole block (`available: false`).",
});
// ── The day's two readings ───────────────────────────────────────────
//
// Published as the RESOLVED value. The server computes the expected value, the
// band, the count, the deviation and the age; the client renders them. Nothing
// here is a hint the client is meant to re-derive — two clients deriving the
// same figure is how they start disagreeing about it, and this figure is about
// somebody's health record.
const moodPrognosisContribution = z
.object({
feature: z
.string()
.describe(
"Stable feature identifier: `dimension:a2`, `context:workLoad`, `context:workStatus=off` or `linked:steps`. A client that does not recognise one skips it — an older row can name a feature a later model dropped.",
),
contribution: z
.number()
.describe(
"Signed: how far this feature moved the expected value away from the account's own average day. Ordered by absolute size, largest first.",
),
})
.meta({ id: "MoodPrognosisContribution" });
const moodPrognosisPresent = z.object({
present: z.literal(true),
date: z.string().describe("The local day this is about, `YYYY-MM-DD`."),
predicted: z
.number()
.describe(
"The expected pleasantness (A1) for that day, 0-10, on the account's own past pattern. A counterfactual, never a measurement — and never to be displayed without `n` and the band.",
),
ciLow: z.number().nullable(),
ciHigh: z
.number()
.nullable()
.describe(
"The band, from the validation residuals. It widens when the model fits badly, so a wide band is the signal to present the value cautiously.",
),
n: z
.number()
.int()
.describe(
"Complete days the fit used. Display it WHEREVER `predicted` is displayed: a value built from 30 days and one built from 300 look identical otherwise.",
),
modelVersion: z.string(),
computedAt: isoDateTime(),
ageDays: z
.number()
.int()
.nullable()
.describe("Whole days between that day and the reader's today."),
current: z
.boolean()
.describe(
"Whether it is fresh enough to back a present-tense sentence. False means the copy must name the day it is about.",
),
provisional: z
.boolean()
.describe("True below 60 days of history: label it as provisional."),
stage: z.enum(["provisional", "regular", "seasonal"]),
entries: z.number().int().describe("Days of history behind the ladder."),
seasonalUnlocked: z.boolean(),
selfAssessment: z
.number()
.int()
.nullable()
.describe(
"The person's own rating for that day, stored exactly as they gave it. It LEADS: it is the primary value on any surface that shows both, and it is never merged with `predicted`.",
),
deviation: z
.enum(["within", "above", "below"])
.nullable()
.describe(
"Where the self-assessment sat against the BAND, not against `predicted`. `within` means the day went as the account's own past days suggest.",
),
deviationAmount: z
.number()
.nullable()
.describe("How far outside the band, in scale points. `0` when inside."),
contributions: z.array(moodPrognosisContribution),
});
const moodPrognosisAbsent = z.object({
present: z.literal(false),
reason: z
.enum(["no-output-yet", "learning-phase", "no-pattern"])
.describe(
"`no-output-yet`: too few days for anything. `learning-phase`: enough to describe, not to compare. `no-pattern`: enough days, nothing steady enough in them. None of the three is an error and none is a zero.",
),
entries: z.number().int(),
nextThreshold: z
.number()
.int()
.nullable()
.describe("Days at which the next step unlocks, when a count is missing."),
});
const moodPrognosisResponse = z
.union([moodPrognosisPresent, moodPrognosisAbsent])
.meta({
id: "MoodPrognosis",
description:
"Two separate readings of one day: what the person recorded, and what a comparison with their own past days would have expected. They are never merged — the distance between them is the finding, and a single blended figure destroys it. The comparison is a regularised regression over the account's own history: observational only, never causal. Any copy built on it must state the value as a counterfactual (a value around X would have been expected), must carry `n`, and must not say that anything caused anything.",
});
// ── Mood insights aggregates (v1.8.5) ────────────────────────────────
//
// `GET /api/mood/insights` is the pre-computed aggregate bundle behind the
// Mood Insights page. The browser never receives raw mood rows from it — the
// compute runs server-side and only pre-shaped blocks leave. Every block is
// observational: with-versus-without comparisons and correlations, never a
// cause and never a verdict.
const moodInfluenceConfidence = z
.enum(["low", "medium", "high"])
.meta({ id: "MoodInfluenceConfidence" });
const moodPairingMode = z
.enum(["sameDay", "nextDay"])
.describe(
"Pairing lag the row was computed at. `nextDay` is the D → D+1 join used for overnight-recovery channels; caption it, because a next-day row says something different from a same-day one.",
);
const moodDailyCell = z.object({
date: z.string().describe("`YYYY-MM-DD` day key."),
score: z.number().describe("Daily mean pleasantness for the day."),
samples: z.number().int().describe("Entries that fed the mean."),
});
const moodDimensionSummary = z
.object({
key: z
.enum(["a1", "a2", "a3", "a4", "a5"])
.describe("Level-A dimension key."),
present: z
.boolean()
.describe(
"False when nobody answered this dimension in the window. The series is then empty and the surface says so rather than drawing a line through the middle of the scale.",
),
inverse: z
.boolean()
.describe(
"True when higher is worse for this dimension. The analytics layer flips through this flag rather than inline, so a client must read it before comparing dimensions.",
),
min: z.number().describe("Scale floor for the dimension."),
max: z.number().describe("Scale ceiling for the dimension."),
count: z.number().int().describe("Entries carrying a value."),
avg7: z.number().nullable(),
avg30: z.number().nullable(),
avg90: z
.number()
.nullable()
.describe("Trailing-window means; null when that window is empty."),
latest: z.number().nullable(),
latestDate: z.string().nullable().describe("`YYYY-MM-DD` of `latest`."),
newestDaysAgo: z.number().int().nullable(),
series: z
.array(
z.object({
date: z.string(),
value: z.number(),
samples: z.number().int(),
}),
)
.describe("Daily means, oldest first, over the longest window."),
})
.meta({ id: "MoodDimensionSummary" });
const moodTagInfluenceRow = z
.object({
tag: z
.string()
.describe("Stable tag key. For a flat tag this is the free text itself."),
labelKey: z
.string()
.nullable()
.describe("i18n label key for a structured tag; null for a flat one."),
label: z
.string()
.nullable()
.optional()
.describe(
"Decrypted custom-tag label. Absent for catalogue and flat tags — the key is absent, not null.",
),
categoryKey: z.string().nullable(),
icon: z.string().nullable(),
withDays: z.number().int(),
withoutDays: z.number().int(),
withAvg: z.number().describe("Mean of daily means on tagged days."),
withoutAvg: z
.number()
.describe("Mean of daily means on the other days (the counterfactual)."),
delta: z
.number()
.describe("`withAvg − withoutAvg`, rounded. Signed, never causal."),
pooledSd: z
.number()
.nullable()
.describe(
"Pooled mood SD across the two groups (the Cohen's-d denominator); null when neither group has testable spread. Ranking input only — not rendered.",
),
pValue: z.number().describe("Welch two-sided p-value."),
confidence: moodInfluenceConfidence,
})
.meta({ id: "MoodTagInfluenceRow" });
const moodAggregatesResponse = z
.object({
summary: z
.object({
points: z.number().int().describe("Daily points behind the figures."),
mean: z.number().nullable(),
min: z.number().nullable(),
max: z.number().nullable(),
latest: z.number().nullable(),
delta: z
.number()
.nullable()
.describe("Latest daily mean minus the previous daily mean."),
inTargetPct: z.number().nullable(),
totalEntries: z.number().int(),
totalSpanDays: z.number().int(),
newestEntryDaysAgo: z.number().int().nullable(),
})
.describe(
"Headline figures. Every statistic is null on an empty window.",
),
heatmap: z.object({
windowDays: z
.union([z.literal(30), z.literal(90), z.literal(365)])
.describe("Adaptive window the cells were cut to."),
cells: z.array(moodDailyCell),
}),
distribution: z
.array(
z.object({
score: z.number().describe("Rounded mood level, 1..5."),
count: z.number().int(),
}),
)
.describe("Entry counts per rounded level."),
dimensions: z
.array(moodDimensionSummary)
.describe(
"One summary per level-A dimension. A dimension nobody answered is still listed, with `present: false`.",
),
weekday: z
.array(
z.object({
weekday: z.number().int().describe("0 = Monday … 6 = Sunday."),
avgScore: z.number().nullable(),
count: z.number().int(),
}),
)
.describe("Per-weekday means; an unlogged weekday carries a null mean."),
timeOfDay: z
.object({
buckets: z.array(
z.object({
bucket: z.enum(["morning", "afternoon", "evening", "night"]),
avgScore: z.number().nullable(),
count: z.number().int(),
}),
),
reliable: z
.boolean()
.describe(
"False for the once-a-day logger (everything in one bucket) or a sparse history. Do not chart or narrate the pattern when this is false.",
),
best: z
.enum(["morning", "afternoon", "evening", "night"])
.nullable()
.describe("Null unless `reliable`."),
worst: z
.enum(["morning", "afternoon", "evening", "night"])
.nullable()
.describe("Null unless `reliable`."),
})
.describe(
"Average pleasantness per part of day, bucketed in each entry's OWN timezone. All four buckets are always listed, in canonical order.",
),
stability: z
.object({
score: z.number().describe("0..100; higher means steadier day-to-day."),
stdDev: z.number().describe("Population SD of the daily means."),
band: z.enum(["verySteady", "steady", "variable", "veryVariable"]),
days: z.number().int(),
})
.nullable()
.describe(
"Day-to-day steadiness over the trailing `stabilityWindowDays` days. Null below the day floor (seven days with an entry) inside that window, so a short window can be null where the year is not.",
),
stabilityWindowDays: z
.union([z.literal(30), z.literal(90), z.literal(180), z.literal(365)])
.describe(
"The window `stability` covers: the `days` query parameter, or 365 (the whole daily series) when it was not sent. Added in v1.42.0; absent from older servers, whose score always covers the year.",
),
tags: z
.array(
z.object({
tag: z.string(),
count: z.number().int(),
avgScore: z.number(),
}),
)
.describe("Flat free-text tag counts with their mean mood."),
contextComparison: z
.array(
z.object({
field: z.string().describe("Context field, e.g. `workStatus`."),
value: z.string().describe("The value inside that field."),
withDays: z.number().int(),
withoutDays: z.number().int(),
withAvg: z.number(),
withoutAvg: z.number(),
delta: z
.number()
.describe("`withAvg − withoutAvg`. Signed, and never an effect."),
pValue: z.number(),
qValue: z.number().describe("Benjamini-Hochberg adjusted p-value."),
}),
)
.describe(
"How the day's pleasantness sat on days carrying each context value against the days that did not. Every row carries the two counts it was computed from so a reader can weigh it.",
),
structuredTags: z
.array(
z.object({
key: z.string(),
categoryKey: z.string(),
labelKey: z.string(),
label: z
.string()
.nullable()
.optional()
.describe("Decrypted custom-tag label; absent for catalogue tags."),
icon: z.string().nullable(),
count: z.number().int(),
avgScore: z.number(),
}),
)
.describe("Taxonomy-tag counts with their mean mood."),
tagInfluence: z
.object({
flat: z.array(moodTagInfluenceRow),
structured: z.array(moodTagInfluenceRow),
})
.describe(
"With-versus-without daily-mean delta plus a Welch confidence band per frequent tag, ranked by |delta| descending.",
),
betterDays: z
.array(
z.object({
source: z.enum(["tag", "metric"]),
key: z
.string()
.describe("Tag key, or the correlation channel key for a metric."),
labelKey: z.string().nullable(),
label: z.string().nullable().optional(),
categoryKey: z.string().nullable(),
icon: z.string().nullable(),
direction: z
.enum(["up", "down"])
.describe("Associated with higher / lower mood."),
n: z.number().int(),
confidence: moodInfluenceConfidence,
effectSize: z
.number()
.describe(
"Unified 0..1 ranking strength (|r| for a metric, min(1, |delta| / 2) for a tag). Ranking only — surface `delta` or `r`, not this.",
),
delta: z
.number()
.nullable()
.describe(
"Raw mood-point delta for a tag row; null for a metric row.",
),
r: z
.number()
.nullable()
.describe("Raw Pearson r for a metric row; null for a tag row."),
}),
)
.describe(
"The effect-size-ranked, confidence-gated board folding the tag deltas and the mood x metric correlations into one observational list.",
),
tagMetricCrosstab: z
.array(
z.object({
tag: z.string(),
labelKey: z
.string()
.describe(
"Structured tags only — this board never carries a flat tag.",
),
label: z.string().nullable().optional(),
categoryKey: z.string(),
icon: z.string().nullable(),
metricKey: z
.string()
.describe(
"Metric channel. Currently `activeEnergy`, `sleepDuration`, `nextDayRecovery`, `nextDayRestingHeartRate` or `nextDayHeartRateVariability`; the registry is a plain map, so treat this as an open token set rather than a closed enum.",
),
display: z
.string()
.describe(
"Display-unit hint for the client formatter (`hours` / `kcal` / `score` / `bpm` / `ms`). The values are ALREADY in that unit.",
),
mode: moodPairingMode,
withDays: z.number().int(),
withoutDays: z.number().int(),
withAvg: z.number(),
withoutAvg: z.number(),
delta: z.number().describe("`withAvg − withoutAvg`, display unit."),
pValue: z.number(),
qValue: z.number(),
confidence: moodInfluenceConfidence,
patternId: z
.string()
.optional()
.describe(
"Persisted pattern id; absent when the row is not persisted.",
),
canonicalKey: z.string().optional(),
dismissed: z
.boolean()
.optional()
.describe("Whether the user dismissed the persisted pattern."),
}),
)
.describe(
"Per structured tag x health metric: the metric's mean on tag-present versus tag-absent days, Welch-tested and BH-corrected. Association, never cause.",
),
factorCrosstab: z
.array(
z.object({
factor: z.string().describe("Stable RATED-factor key."),
labelKey: z.string(),
categoryKey: z.string(),
icon: z.string().nullable(),
inverse: z
.boolean()
.describe(
"True for an inverse-scaled factor (stress, conflict). The split already runs on the flipped series, so a `low` row always means a worse day; the flag only changes the phrasing.",
),
metricKey: z
.string()
.describe(
"Metric channel. Currently `sleepDuration`, `steps`, `restingHeartRate`, `heartRateVariability`, `weight` or `bloodPressureSystolic`; an open token set, as above.",
),
display: z
.string()
.describe(
"Display-unit hint (`hours` / `score` / `steps` / `bpm` / `ms` / `kg` / `mmHg`).",
),
mode: moodPairingMode,
lowDays: z.number().int(),
highDays: z.number().int(),
lowAvg: z.number(),
highAvg: z.number(),
delta: z
.number()
.describe(
"`lowAvg − highAvg`, display unit. Negative means the vital runs lower on low-factor days.",
),
pValue: z.number(),
qValue: z.number(),
confidence: moodInfluenceConfidence,
patternId: z.string().optional(),
canonicalKey: z.string().optional(),
dismissed: z.boolean().optional(),
}),
)
.describe(
"The cross-domain bridge: per rated factor x health metric, the vital's mean on the days the factor was rated LOW versus HIGH (median split), Welch-tested and BH-corrected. Association, never cause.",
),
narratives: z
.array(
z.object({
kind: z.enum([
"weekday-dip",
"weekday-peak",
"trend",
"weekend",
"tag-lift",
"tag-drop",
"in-target",
"streak",
"time-of-day",
]),
messageKey: z
.string()
.describe("i18n template key the client resolves."),
vars: z
.record(z.string(), z.string())
.describe(
"Interpolation values, all stringified so they survive transport unchanged.",
),
strength: z
.number()
.describe("Normalised ranking weight; higher is stronger."),
}),
)
.describe(
"Ranked, threshold-gated takeaways — the read-this-first layer above the charts. The same array feeds the LLM snapshot so prose and feed cannot drift.",
),
correlations: z
.record(
z.string(),
z.object({
result: z
.object({
r: z.number(),
strength: z
.enum(["stark", "moderat", "schwach", "keine"])
.describe(
"Strength bucket. The tokens are GERMAN on the wire — a legacy of where the helper was written; treat them as opaque identifiers and localise client-side rather than displaying them.",
),
n: z.number().int(),
})
.nullable()
.describe("Null when the pairing produced no testable series."),
points: z
.array(z.object({ x: z.number(), y: z.number() }))
.describe("Mood (x) versus metric (y), paired by day."),
n: z.number().int().describe("Paired-day count."),
suppressed: z
.enum(["insufficientPairs", "notSignificant"])
.optional()
.describe(
"Why `result` is null: too few paired days, or a pairing that did not reach significance. Absent when `result` is set.",
),
}),
)
.describe(
"Mood against five health channels, keyed `sleep`, `steps`, `pulse`, `weight`, `bloodPressureSystolic`. Every key is always present; an empty channel carries `result: null` with no points.",
),
patterns: z
.array(moodPattern)
.optional()
.describe(
"v1.43 — the patterns that passed the gate over the `?days` window, on the account's mood day. Empty means none was found.",
),
})
.meta({
id: "MoodAggregates",
description:
"The pre-computed Mood Insights bundle: headline summary, heatmap, distribution, per-dimension summaries, weekday and time-of-day patterns, stability, tag boards, context and crosstab comparisons, narratives and cross-metric correlations. Everything is observational — comparisons and correlations, never a cause and never a verdict. Served through a 60 s stale-while-revalidate cache; a mood write marks the cell so the account's own entry lands on the next read.",
});
const moodDailySeriesResponse = z
.object({
entries: z
.array(
z.object({
date: z.string().describe("`YYYY-MM-DD` day key."),
score: z
.number()
.describe("The day's mean pleasantness on the 1..5 scale."),
samples: z.number().int().describe("Entries behind the mean."),
}),
)
.describe("One entry per day that carries a mood, oldest first."),
summary: z
.object({
count: z.number().int(),
latest: z.number().nullable(),
min: z.number().nullable(),
max: z.number().nullable(),
mean: z.number().nullable(),
median: z.number().nullable(),
avg7: z.number().nullable(),
avg30: z.number().nullable(),
slope7: z
.object({
slope: z.number(),
direction: z.enum(["up", "down", "stable"]),
confidence: z.number(),
})
.nullable(),
slope30: z
.object({
slope: z.number(),
direction: z.enum(["up", "down", "stable"]),
confidence: z.number(),
})
.nullable(),
anomalyCount: z.number().int().optional(),
avg30LastMonth: z.number().nullable().optional(),
avg30LastYear: z.number().nullable().optional(),
})
.describe(
"The shared summary shape computed over the DAILY MEANS, not over the raw entries — so `count` is the number of days with a mood, not the number of moods logged. Every statistic is null on an empty series rather than 0.",
),
})
.meta({
id: "MoodDailySeries",
description:
"The canonical daily mood series. `entryCount` and the rollup-versus-live `source` the builder also resolves are annotated for observability and deliberately NOT on this wire — the two keys below are the whole payload.",
});
export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/mood/analytics": {
get: {
tags: ["Mood"],
summary: "The canonical daily mood series",
description:
"One series, read by the mood page sparkline, the dashboard tile and the native client alike, so all three render the same number — the dashboard snapshot resolves it through the same builder rather than aggregating again. Answered from the mood rollup tier with a live fallback on a coverage miss; which tier answered is annotated for operators and is not on the wire. Plain read-through cache (NOT stale-while-revalidate, unlike the insights sibling). Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.",
responses: {
"200": {
description: "The daily series and its summary.",
content: {
"application/json": {
schema: dataEnvelope(
moodDailySeriesResponse,
"MoodDailySeriesEnvelope",
),
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
"/api/mood/insights": {
get: {
tags: ["Mood"],
summary: "Pre-computed mood aggregates (v1.8.5)",
description:
"The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach. v1.42.0 — `days` cuts `stability` to the period a client shows; every other block is unchanged by it.",
requestParams: {
query: z.object({
days: z
.enum(["30", "90", "180", "365"])
.optional()
.describe(
"Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422.",
),
}),
},
responses: {
"200": {
description: "The aggregate bundle.",
content: {
"application/json": {
schema: dataEnvelope(
moodAggregatesResponse,
"MoodAggregatesEnvelope",
),
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
"/api/mood/prognosis": {
get: {
tags: ["Mood"],
summary: "The day's own rating and what the account's days imply (v1.38)",
description:
"The newest stored comparison for the account, with the person's own rating for that day beside it. Written nightly by a job and never by a request: the value cannot be overwritten, which is what keeps the two readings separate. Absence is explicit (`present: false` with a reason and a count) and is a normal answer, not an error — the comparison refuses when the history does not support one. Whole-record read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and it names them, so a section-scoped grant is refused rather than served a filtered list. Gated: `module.disabled` 403 when the mood module is off.",
responses: {
"200": {
description: "The day's two readings, or an explained absence.",
content: {
"application/json": {
schema: dataEnvelope(
moodPrognosisResponse,
"MoodPrognosisEnvelope",
),
},
},
},
// Two reasons, one status: the module gate and the sharing fence both
// answer 403, and OpenAPI allows one response per status.
...recordRefusal(MODULE_DISABLED_DESCRIPTION),
...stdResponses,
},
},
},
"/api/mood-entries/bulk": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Mood"],
summary: "Bulk mood backfill (iOS SyncMode)",
description:
"Drains the iOS local mood log in one shot: up to 500 entries per call with per-entry UPSERT semantics keyed by `externalId`, so an adopt-on-pair backfill or a retried batch is idempotent. Idempotent via the `Idempotency-Key` header too. Per-entry status (`inserted` / `duplicate` / `skipped`) advances the client cursor. Rate-limited 60/min/user. An over-size batch 422s (`meta.errorCode = mood.bulk.too_large`); a malformed body 422s (`mood.bulk.invalid`).",
requestBody: {
required: true,
content: { "application/json": { schema: bulkMoodPayload } },
},
responses: {
...idempotentWrite(),
"200": {
description: "Batch processed (always 200 on a well-formed body).",
content: {
"application/json": {
schema: dataEnvelope(
bulkMoodResponse,
"BulkMoodResponseEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/mood/tags": {
get: {
tags: ["Mood"],
summary: "Effective per-user mood-tag tree (v1.8.5 / v1.13.0 / v1.17.0)",
description:
"The fully-resolved, ordered Category → Tag tree: seeded catalogue + the caller's custom tags and groups, per-user layout (group order + placements) applied server-side, hidden catalogue tags omitted. `include` is a comma list: `hidden` (hidden catalogue tags, `hidden:true`), `archived` (own inactive custom tags, `archived:true`), `usage` (per-tag `usageCount`). With any flag set, the caller's own empty groups are kept; the plain read drops empty categories.",
requestParams: {
query: z.object({
include: z
.string()
.optional()
.describe("Comma list of `hidden`, `archived`, `usage`."),
}),
},
responses: {
"200": {
description: "The effective tag tree.",
content: {
"application/json": {
schema: dataEnvelope(moodTagTreeResponse, "MoodTagTreeEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/mood/tags/custom": {
post: {
tags: ["Mood"],
summary: "Create a custom mood tag (v1.13.0 / v1.17.0)",
description:
"Mints a `custom:<uuid>` key, encrypts the label at rest, stores a BINARY tag owned by the caller under the chosen group (default: the seeded `custom` category). 422 over the 50-tag cap or on an unknown / foreign `categoryKey`.",
requestBody: {
required: true,
content: { "application/json": { schema: createMoodTagRequest } },
},
responses: {
"201": {
description: "Custom tag created.",
content: {
"application/json": {
schema: dataEnvelope(
moodCustomTagResponse,
"MoodCustomTagCreatedEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/mood/tags/custom/{key}": {
patch: {
tags: ["Mood"],
summary: "Update a custom mood tag (v1.13.0 / v1.17.0)",
description:
"Owner-scoped: another user's key or a catalogue key 404s. Rename (re-encrypts), re-icon, archive/restore via `isActive`, or move groups via `categoryKey`.",
requestParams: { path: z.object({ key: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateMoodTagRequest } },
},
responses: {
"200": {
description: "Custom tag updated.",
content: {
"application/json": {
schema: dataEnvelope(
moodCustomTagPatchResponse,
"MoodCustomTagPatchEnvelope",
),
},
},
},
...notFoundResponse,
...stdResponses,
},
},
delete: {
tags: ["Mood"],
summary: "Archive or purge a custom mood tag (v1.13.0)",
description:
"Default: soft-deactivates (`isActive:false`) — history intact, restorable via PATCH. `?purge=true`: hard-deletes the row; the FK cascade removes its entry links. Owner-scoped 404.",
requestParams: {
path: z.object({ key: z.string() }),
query: z.object({ purge: z.enum(["true", "false"]).optional() }),
},
responses: {
"200": {
description: "Archived (or purged).",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ key: z.string(), purged: z.boolean() }),
"MoodCustomTagDeleteEnvelope",
),
},
},
},
...notFoundResponse,
...stdResponses,
},
},
},
"/api/mood/tags/{key}/hidden": {
put: {
tags: ["Mood"],
summary: "Hide / show a catalogue mood tag (v1.13.0)",
description:
"Per-user hide of a CATALOGUE tag (upserts / removes a `mood_tag_hidden` row). 400 on a `custom:` key — custom tags archive via their own `isActive`.",
requestParams: { path: z.object({ key: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: hideMoodTagRequest } },
},
responses: {
"200": {
description: "Visibility updated.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ key: z.string(), hidden: z.boolean() }),
"MoodTagHiddenEnvelope",
),
},
},
},
"400": {
description: "Custom-tag key — use the custom PATCH instead.",
content: { "application/json": { schema: errorEnvelope } },
},
...notFoundResponse,
...stdResponses,
},
},
},
"/api/mood/tags/groups": {
post: {
tags: ["Mood"],
summary: "Create a custom mood-tag group (v1.17.0)",
description:
"Mints a `customcat:<uuid>` key, encrypts the label at rest, stores a group owned by the caller. 422 over the 12-group cap.",
requestBody: {
required: true,
content: { "application/json": { schema: createMoodTagGroupRequest } },
},
responses: {
"201": {
description: "Custom group created.",
content: {
"application/json": {
schema: dataEnvelope(
moodTagGroupResponse,
"MoodTagGroupCreatedEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/mood/tags/groups/{key}": {
patch: {
tags: ["Mood"],
summary: "Update a custom mood-tag group (v1.17.0)",
description:
"Owner-scoped: another user's key or a seeded category key 404s. Rename (re-encrypts), re-icon, retire/restore via `isActive`.",
requestParams: { path: z.object({ key: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateMoodTagGroupRequest } },
},
responses: {
"200": {
description: "Custom group updated.",
content: {
"application/json": {
schema: dataEnvelope(
moodTagGroupPatchResponse,
"MoodTagGroupPatchEnvelope",
),
},
},
},
...notFoundResponse,
...stdResponses,
},
},
delete: {
tags: ["Mood"],
summary: "Delete a custom mood-tag group (v1.17.0)",
description:
"Non-destructive: the group's custom tags re-home to the seeded `custom` category, catalogue-tag placements evaporate back to their seeded category, the layout drops the group, then the row soft-deactivates (default) or hard-deletes (`?purge=true`). No tag and no entry link is deleted.",
requestParams: {
path: z.object({ key: z.string() }),
query: z.object({ purge: z.enum(["true", "false"]).optional() }),
},
responses: {
"200": {
description: "Group deleted; `rehomedCount` custom tags moved.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
key: z.string(),
purged: z.boolean(),
rehomedCount: z.number().int(),
}),
"MoodTagGroupDeleteEnvelope",
),
},
},
},
...notFoundResponse,
...stdResponses,
},
},
},
"/api/mood/tags/layout": {
get: {
tags: ["Mood"],
summary: "Read the per-user mood-tag layout (v1.17.0)",
description:
"Returns the stored blob merged over defaults: `groupOrder` resolved against the caller's effective category set (seeded + own groups), `placements` as stored.",
responses: {
"200": {
description: "Resolved layout.",
content: {
"application/json": {
schema: dataEnvelope(
moodTagLayoutResolved,
"MoodTagLayoutGetEnvelope",
),
},
},
},
...stdResponses,
},
},
put: {
tags: ["Mood"],
summary: "Update the per-user mood-tag layout (v1.17.0)",
description:
"Preserve-when-absent merge: a `groupOrder`-only PUT keeps the stored `placements` and vice versa. Bounded: ≤ 50 groups, ≤ 400 placement entries, keys ≤ 80 chars. Returns the resolved layout.",
requestBody: {
required: true,
content: { "application/json": { schema: moodTagLayoutPutRequest } },
},
responses: {
"200": {
description: "Merged + resolved layout with the fresh token.",
content: {
"application/json": {
schema: dataEnvelope(
moodTagLayoutResolved,
"MoodTagLayoutPutEnvelope",
),
},
},
},
...conflictResponse409("Mood-tag layout", "mood_tag_layout_conflict"),
...stdResponses,
...invalidBaseTokenResponse,
},
},
},
// ── Entry CRUD (published later than its bulk twin; see file header) ─
"/api/mood-entries": {
get: {
tags: ["Mood"],
summary: "List mood entries",
description:
"The record's mood entries, filtered and paged. Soft-deleted (tombstoned) rows are hidden; the `/api/sync/changes` feed is where tombstones surface. Every row carries the same `MoodEntryDTO` shape the write paths answer, structured-tag keys and rated factors included, so the edit form pre-populates without per-row requests. A malformed query 422s with every issue listed (`meta.errorCode` = `mood.list.invalid`).",
requestParams: { query: listMoodEntriesQuery },
responses: {
"200": {
description: "One page of entries plus paging meta.",
content: {
"application/json": {
schema: dataEnvelope(
moodEntryListResponse,
"MoodEntryListEnvelope",
),
},
},
},
...recordRefusal(),
...stdResponses,
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Mood"],
summary: "Create a mood entry",
description:
"Writes the entry, its structured-tag links and its day context in one transaction — a link or context failure rolls the entry back rather than letting a retry mint a duplicate. Idempotent via the `Idempotency-Key` header. With `externalId` the write UPSERTS on `(userId, source, externalId)` (a re-post updates the row in place and restates pleasantness from the label, while A2-A5 and a stored context are only touched when the request carries them); without it, a same-`(date, moodLoggedAt)` re-post 409s. A delegated manager cannot supply `externalId` (422, `meta.errorCode` = `mood.create.external_id_not_delegable`) — it is a device's contract with its own rows. A rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a malformed body 422s (`mood.create.invalid`). An unstable `externalId` (an object description or memory address that rotates per launch) is refused at validation.",
requestBody: {
required: true,
content: { "application/json": { schema: createMoodEntryRequest } },
},
responses: {
...idempotentWrite(),
"201": {
description:
"Entry created (or upserted in place via `externalId`), with its persisted tag keys, rated factors and context.",
content: { "application/json": { schema: moodEntryEnvelope } },
},
...duplicateTimestampResponse,
...recordRefusal(),
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
"/api/mood-entries/{id}": {
get: {
tags: ["Mood"],
summary: "Read one mood entry",
description:
"The full entry with its day context, so the edit surface loads both in one request. A soft-deleted row 404s, matching the list read's invariant.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "The entry.",
content: { "application/json": { schema: moodEntryEnvelope } },
},
...notFoundResponse,
...recordRefusal(),
...stdResponses,
},
},
put: {
tags: ["Mood"],
summary: "Edit a mood entry",
description:
"Per-field edit: omitted fields keep their stored value; `tagKeys`, `ratedFactors` and `context` replace their whole set when present (`null` clears). Editing `mood` re-derives `score` and — when the label actually moved — pleasantness `a1`; editing `moodLoggedAt` re-anchors the row's `date` and `tz` to the account's display timezone. Bumps `syncVersion` so paired clients reconcile last-writer-wins. A soft-deleted row 404s (`meta.errorCode` = `mood.not_found`); an edit landing on another row's `(date, moodLoggedAt)` 409s (`mood.duplicate_timestamp`); a rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a malformed body 422s (`mood.update.invalid`).",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateMoodEntryRequest } },
},
responses: {
"200": {
description:
"The entry after the edit, with its persisted tag keys, rated factors and context.",
content: { "application/json": { schema: moodEntryEnvelope } },
},
...notFoundResponse,
...duplicateTimestampResponse,
...recordRefusal(),
...stdResponses,
},
},
delete: {
tags: ["Mood"],
summary: "Delete a mood entry (soft)",
description:
"Tombstones the row (`deletedAt` set, `syncVersion` bumped) rather than hard-deleting, so the `/api/sync/changes` feed carries the tombstone to paired clients that were offline. Invisible to every normal read from here on; `POST /api/mood-entries/restore` puts it back. Re-deleting an already-tombstoned row is a harmless idempotent no-op.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Entry tombstoned.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.literal(true) }),
"MoodEntryDeleteEnvelope",
),
},
},
},
...notFoundResponse,
...recordRefusal(),
...stdResponses,
},
},
},
"/api/mood-entries/restore": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Mood"],
summary: "Restore soft-deleted mood entries",
description:
"Un-tombstones every owned, currently-deleted id in one statement — the delete-Undo affordance. Clears `deletedAt` and bumps `syncVersion` so the row re-surfaces in normal reads and rides the `/api/sync/changes` feed as an upsert. A forged / foreign / not-deleted id is a silent no-op, never a 404 existence leak. Idempotent via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user, so a manager burns their own allowance). A malformed body 422s (`meta.errorCode` = `mood.restore.invalid`).",
requestBody: {
required: true,
content: { "application/json": { schema: moodEntryIdsPayload } },
},
responses: {
...idempotentWrite(),
"200": {
description: "`restored` counts the rows actually un-tombstoned.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ restored: z.number().int().nonnegative() }),
"MoodEntriesRestoreEnvelope",
),
},
},
},
...recordRefusal(),
...stdResponses,
},
},
},
"/api/mood-entries/bulk-delete": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Mood"],
summary: "Soft-delete mood entries in bulk",
description:
"Tombstones every owned, not-already-deleted id in one statement — the management list's multi-select delete, matching the single-DELETE tombstone contract. A forged / foreign id is a silent no-op, never a 404 existence leak. Idempotent via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user). A malformed body 422s (`meta.errorCode` = `mood.bulk-delete.invalid`).",
requestBody: {
required: true,
content: { "application/json": { schema: moodEntryIdsPayload } },
},
responses: {
...idempotentWrite(),
"200": {
description: "`deleted` counts the rows actually tombstoned.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.number().int().nonnegative() }),
"MoodEntriesBulkDeleteEnvelope",
),
},
},
},
...recordRefusal(),
...stdResponses,
},
},
},
"/api/mood/linked-context": {
get: {
tags: ["Mood"],
summary: "The linked figures of one local day",
description:
"What the modules that own sleep, activity, vitals and body symptoms already know about one day, resolved at read time and never stored on the mood row — a corrected sleep session shows up here with nothing to re-sync. Keyed by local day rather than entry id, deliberately: the capture sheet needs the figures BEFORE an entry exists. `tz` anchors the day's boundaries — the capture sheet sends the browser's zone, the edit dialog the entry's stored `tz`; absent falls back to the account's display timezone. Whole-record read: the payload crosses into the measurements and illness sections from a mood surface, so a section-scoped grant is refused outright rather than served a filtered day that would read as empty. Gated: 403 `module.disabled` when the mood module is off. A malformed query 422s (`meta.errorCode` = `mood.linkedContext.invalid`).",
requestParams: {
query: z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe("The local day to resolve, YYYY-MM-DD."),
tz: z
.string()
.min(1)
.max(64)
.optional()
.describe(
"IANA zone the day is anchored to. Send the entry's stored `tz` when editing; omit to use the account's display timezone.",
),
}),
},
responses: {
"200": {
description:
"The day's linked blocks; absence is explicit per figure and per module.",
content: {
"application/json": {
schema: dataEnvelope(
moodLinkedContextResponse,
"MoodLinkedContextEnvelope",
),
},
},
},
...recordRefusal(MODULE_DISABLED_DESCRIPTION),
...stdResponses,
},
},
},
};