Publish the mood-entries family, the linked-context read and the import endpoint

Ten operations the spec owed: the seven mood-entries routes (list, create,
read, edit, delete, restore, bulk-delete) whose /bulk twin was already
published, GET /api/mood/linked-context (a cross-section read through the
mood URL that grant-scope reviewers need visible), and POST /api/import
(the spec documented polling an import job with no way to create one).
Every schema mirrors the handler's own Zod source; the envelope, refusal
and error semantics are read off the routes, not invented. Request-body
ids are bound instances now — a bare .meta() call clones in Zod 4 and
never reaches components.schemas.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 09:23:34 +02:00
1 parent 832aad9dd8
commit 57ec22073c
2 files changed
+617 -16

No files matched your search

+165 -7
View File
@@ -1,17 +1,23 @@
/**
* OpenAPI route table — CSV measurement import (v1.17.1).
* OpenAPI route table — data import (CSV since v1.17.1, JSON since the
* publication sweep that closed the create/poll gap).
*
* The CSV importer is the cold-start escape hatch for self-hosters migrating
* from a spreadsheet or another tracker. The JSON (`/api/import`) and Apple-
* Health (`/api/import/apple-health-export`) routes predate the contract
* surface and are intentionally not documented here; this module documents
* the one new endpoint so the wire contract stays drift-free (CI gate). iOS
* does not call it — its canonical ingest is `POST /api/measurements/batch`.
* from a spreadsheet or another tracker. The JSON importer (`/api/import`)
* restores the structure `GET /api/export/json` emits. The Apple-Health
* route (`/api/import/apple-health-export`) predates the contract surface
* and remains undocumented here. iOS calls neither — its canonical ingest is
* `POST /api/measurements/batch`.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { dataEnvelope, stdResponses } from "./shared";
import {
glucoseContextEnum,
measurementTypeEnum,
} from "@/lib/validations/measurement";
import { moodLevelEnum } from "@/lib/validations/mood";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
const csvImportRowResult = z
.object({
@@ -47,6 +53,126 @@ const csvImportResponse = z
"Per-row import outcome. Mirrors the batch-route per-entry envelope. In `dryRun` mode no write runs and every valid row reports its projected `inserted` status.",
});
// ── JSON import — mirrors the route-local `importSchema`
// (`src/app/api/import/route.ts`), which is deliberately not exported: the
// runtime side carries transforms + plausibility refinements the document
// restates in prose.
const jsonImportMeasurement = z
.object({
type: measurementTypeEnum,
value: z
.number()
.describe(
"Checked against the type's server-side plausibility range; an out-of-range value fails the whole request with 422.",
),
unit: z.string(),
measuredAt: z.iso
.datetime({ offset: true })
.describe(
"Bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900.",
),
glucoseContext: glucoseContextEnum
.optional()
.describe(
"Optional even on a BLOOD_GLUCOSE row — a sensor export classifies nothing per sample.",
),
source: z
.string()
.optional()
.describe(
"Accepted for export-format compatibility and ignored: every imported row stores `source = IMPORT`.",
),
notes: z.string().optional().describe("Encrypted at rest."),
externalId: z
.string()
.min(1)
.max(120)
.optional()
.describe(
"Source-stable id making re-import idempotent (upsert on `(userId, type, source=IMPORT, externalId)`; a re-import updates the row in place). Absent → first-write-wins create with a NULL key, which DOES duplicate on re-import. An unstable id (an object description or memory address that rotates per launch) skips THIS row, never the file.",
),
})
.meta({
id: "JsonImportMeasurement",
description: "One measurement row of a JSON import file.",
});
const jsonImportMoodEntry = z
.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe("Local-day label, YYYY-MM-DD."),
mood: moodLevelEnum,
score: z
.number()
.int()
.min(1)
.max(5)
.describe(
"Stored as sent — the export format's own field. Pleasantness (`a1`) is re-derived from `mood` on every imported row, never taken from this.",
),
tags: z
.string()
.optional()
.describe("JSON-encoded array of tag strings, stored verbatim."),
loggedAt: z.iso
.datetime({ offset: true })
.optional()
.describe(
"Bounded like `measuredAt`. Absent → the server derives a stable per-row instant within the `date` day, so a re-import of the same file dedups instead of duplicating.",
),
externalId: z
.string()
.min(1)
.max(120)
.optional()
.describe(
"Source-stable id (e.g. a Daylio row id) making re-import idempotent (upsert on `(userId, source=IMPORT, externalId)`). Same stability rule as the measurement `externalId`: an unstable id skips the row.",
),
})
.meta({
id: "JsonImportMoodEntry",
description: "One mood-entry row of a JSON import file.",
});
const jsonImportRequest = z
.object({
measurements: z.array(jsonImportMeasurement).max(10000).optional(),
moodEntries: z.array(jsonImportMoodEntry).max(10000).optional(),
})
.meta({
id: "JsonImportRequest",
description:
"JSON import payload, matching the structure `GET /api/export/json` emits. Both sections optional; up to 10 000 rows each.",
});
const jsonImportStats = z
.object({
measurements: z
.number()
.int()
.nonnegative()
.describe("Measurement rows written (inserted or updated in place)."),
moodEntries: z
.number()
.int()
.nonnegative()
.describe("Mood-entry rows written (inserted or updated in place)."),
skipped: z
.number()
.int()
.nonnegative()
.describe(
"Rows not written: duplicates of existing rows, and rows carrying an unstable `externalId`.",
),
})
.meta({
id: "JsonImportStats",
description: "Per-section outcome counts of a JSON import.",
});
export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/import/csv": {
post: {
@@ -100,4 +226,36 @@ export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/import": {
post: {
tags: ["Import"],
summary: "Import measurements and mood entries from JSON",
description:
"Restores a JSON export (the structure `GET /api/export/json` emits): up to 10 000 measurements plus 10 000 mood entries per call, body ≤ 16 MB. Rows carrying an `externalId` upsert idempotently (re-import updates in place); rows without one are first-write-wins and dedup against the existing unique keys, so an exact duplicate is `skipped` rather than doubled. A row with an unstable `externalId` is skipped while the rest of the file still lands. Every imported measurement stores `source = IMPORT`; every imported mood entry re-derives pleasantness from its label. Writes are per-row, not transactional: on a mid-file write failure the response is 503 (`meta.errorCode` = `import.write_failed`, `meta.retryable` = true) with `meta.stats` carrying what landed — rows already written stay written, and a retry is only safe against duplication for rows that carry an `externalId`. Validation is fail-fast (the 422 carries the first issue's message, not the multi-issue envelope). Rate-limited 5/hour per user (shared `import:` bucket with the CSV importer).",
requestBody: {
required: true,
content: { "application/json": { schema: jsonImportRequest } },
},
responses: {
"200": {
description: "Import completed; per-section outcome counts.",
content: {
"application/json": {
schema: dataEnvelope(jsonImportStats, "JsonImportStatsEnvelope"),
},
},
},
...stdResponses,
"413": {
description: "Body exceeds the 16 MB limit.",
content: { "application/json": { schema: errorEnvelope } },
},
"503": {
description:
"A write failed partway through the file. `meta.errorCode` = `import.write_failed`, `meta.retryable` = true, `meta.stats` carries the counts that landed before the failure. Rows already written stay written; retry the same file — rows with `externalId` re-import idempotently, rows without one may duplicate.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
};
+452 -9
View File
@@ -1,16 +1,23 @@
/**
* OpenAPI route table — mood-tag taxonomy management (catalog read, custom
* tags, custom groups, per-user layout, catalogue hide).
* 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/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/tags/`.
* 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 surface so the iOS
* client gets a locked contract.
* 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 { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
@@ -23,7 +30,13 @@ import {
hideCatalogueTagSchema,
} from "@/lib/mood/custom-tags";
import { moodTagLayoutSchema } from "@/lib/mood/tag-layout";
import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood";
import {
createMoodEntrySchema,
listMoodEntriesSchema,
moodLevelEnum,
moodSourceEnum,
updateMoodEntrySchema,
} from "@/lib/validations/mood";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
@@ -76,6 +89,27 @@ hideCatalogueTagSchema.meta({
"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).",
});
// 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 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.",
});
moodTagLayoutSchema.meta({
id: "MoodTagLayout",
description:
@@ -388,6 +422,213 @@ const bulkMoodResponse = z
})
.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 Europe/Berlin).",
),
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: z.iso.datetime({ offset: true }),
tz: z
.string()
.nullable()
.describe(
"IANA zone the `date` label is anchored to. Null on legacy rows (read as Europe/Berlin).",
),
syncedAt: z.iso.datetime({ offset: true }),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
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.",
),
})
.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
@@ -814,4 +1055,206 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
// ── 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: {
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: {
"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,
},
},
},
"/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: {
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: {
"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: {
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: {
"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,
},
},
},
};