Files
HealthLog/src/lib/validations/mood.ts
T
Marc-André Bombeck a25ec211cd feat(mood): capture and persist a mood entry's day context
- moodContextSchema derives every enum from the vocabulary, so a value it does
  not name answers 422 through returnAllZodIssues rather than being dropped
- the context is written in the same transaction as the entry and its links; a
  failure rolls the entry back rather than leaving a client retrying a 5xx
  into a duplicate
- an entry with nothing filled in stores no row, and a context that says
  nothing removes the one that was there
- the create, list, detail and edit reads answer the context with its lists
  decoded and its note decrypted; the ciphertext never leaves the server
2026-08-08 07:07:03 +02:00

318 lines
13 KiB
TypeScript

/**
* Mood-entry validation schemas.
*
* v1.32.33 — the file previously also carried the credential, webhook and
* pull-response schemas of the retired moodLog bridge. Mood is tracked
* natively, so only the entry schemas remain and the module is named for
* what it validates.
*/
import { z } from "zod/v4";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { assertStableExternalId } from "@/lib/validations/external-id";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
CONTEXT_MINUTES_MAX,
CONTEXT_MINUTES_MIN,
CONTEXT_RATING_MAX,
CONTEXT_RATING_MIN,
CONTEXT_VALENCE_MAX,
CONTEXT_VALENCE_MIN,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
} from "@/lib/mood/context-vocabulary";
// --- CRUD schemas for mood entries ---
export const moodLevelEnum = z.enum([
"SUPER_GUT",
"GUT",
"OKAY",
"SCHLECHT",
"LAUSIG",
]);
/**
* Provenance of a stored mood entry. `MOODLOG` is a legacy value: the bridge
* that wrote it was removed in v1.32.33, but rows it created are the user's
* own history and keep their label, so the list filter and the source badge
* must keep resolving it. Nothing mints new `MOODLOG` rows.
*/
export const moodSourceEnum = z.enum([
"MANUAL",
"MOODLOG",
"WEB",
"TELEGRAM",
"DAYLIO",
]);
const MOOD_SCORE_MAP: Record<string, number> = {
SUPER_GUT: 5,
GUT: 4,
OKAY: 3,
SCHLECHT: 2,
LAUSIG: 1,
};
export function getScoreForMood(mood: string): number {
return MOOD_SCORE_MAP[mood] ?? 3;
}
/**
* The five-point mood label mapped onto the level-A pleasantness scale (A1).
*
* The quick check-in asks one question and offers five faces, and it stays a
* full-value capture path: its answer produces a real A1 rather than a
* degraded one. This is the single place those five numbers exist. A second
* copy anywhere in the tree — including inside a migration — is a drift
* waiting to happen, which is why the backfill's SQL is read back and compared
* against this map by a test rather than trusted to stay in step.
*
* The endpoints are 1 and 9, not 0 and 10. "LAUSIG" means as bad as this
* instrument can express, not as bad as a person can feel; pinning it to 0
* would make every mapped historical entry look more extreme than every future
* hand-set one and would leave the true extremes unreachable. 5 is the
* concept's neutral midpoint and is where "OKAY" belongs.
*/
const MOOD_A1_MAP: Record<string, number> = {
SUPER_GUT: 9,
GUT: 7,
OKAY: 5,
SCHLECHT: 3,
LAUSIG: 1,
};
/**
* A1 for a five-point mood label. An unrecognised label answers the neutral
* midpoint, matching what `getScoreForMood` answers for the same input.
*/
export function getA1ForMood(mood: string): number {
return MOOD_A1_MAP[mood] ?? 5;
}
/** Read-only view of the A1 map, for the guards that pin the backfill to it. */
export function moodA1Map(): Readonly<Record<string, number>> {
return MOOD_A1_MAP;
}
// v1.8.5 — structured-tag keys picked from the catalog (`mood_tags.key`).
// Additive alongside the flat free-text `tags`: an entry can carry both.
// Bounded so a single create can't fan out an unbounded link set.
const structuredTagKeys = z.array(z.string().max(60)).max(30);
// v1.12.0 — rated mood factors. A factor is a catalog `MoodTag` of
// `kind = 'RATED'`; the user scores it per entry, and the score persists
// on `MoodEntryTagLink.rating`. The wire shape is a parallel array to
// `tagKeys` (binary), keeping the binary contract byte-identical and the
// iOS Codable model simple (`[{ key, rating }]`).
//
// The Zod `rating` bound here is the OUTER envelope (1..5 covers every
// seeded factor's scale). The REAL gate is per-tag: after resolving each
// key to its `MoodTag`, the server rejects a rating outside the tag's own
// `scaleMin..scaleMax` (every seeded factor is 1..5 today, but a factor
// with a tighter scale is rejected here too). See `resolveRatedFactors`
// in `src/lib/mood/tag-links.ts`.
const ratedFactor = z.object({
key: z.string().max(60),
rating: z.number().int().min(1).max(5),
});
const ratedFactors = z.array(ratedFactor).max(30);
// v1.37 — the five level-A self-state values, each 0-10. Optional on every
// path: a client that sends only a five-point label still writes a complete
// entry, because the server derives A1 from the label. A2 to A5 are only ever
// what the user set, so an omitted one stays absent rather than defaulting to
// the middle of the scale. The bound is the scale itself; `MOOD_DIMENSIONS`
// (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean.
const levelADimension = z.number().int().min(0).max(10);
// v1.38 — the day's context. Every enum below derives from
// `context-vocabulary.ts`, which is what makes "a value the vocabulary does
// not name cannot be stored" structural rather than a convention: adding a key
// to the stored set means adding it to the list the labels and the analytics
// read too, in one edit.
const contextRating = z
.number()
.int()
.min(CONTEXT_RATING_MIN)
.max(CONTEXT_RATING_MAX);
const contextMinutes = z
.number()
.int()
.min(CONTEXT_MINUTES_MIN)
.max(CONTEXT_MINUTES_MAX);
/**
* Optional on create and on patch, and every field inside it optional too.
*
* The bounds live here rather than as database CHECKs, matching
* `MoodEntryTagLink.rating`: an out-of-range value is a client error and
* answers 422 through `returnAllZodIssues`, and a later change to a scale
* cannot strand rows written under the old one.
*
* Sleep, activity, vitals and body symptoms are deliberately absent. They are
* owned by other modules and are read from them
* (`src/lib/mood/linked-context.ts`); accepting them here would mint a second
* home for the same fact.
*/
export const moodContextSchema = z.object({
workStatus: z.enum(WORK_STATUS_KEYS).nullable().optional(),
workMinutes: contextMinutes.nullable().optional(),
overtimeMinutes: contextMinutes.nullable().optional(),
workLoad: contextRating.nullable().optional(),
workSatisfaction: contextRating.nullable().optional(),
contactCircles: z
.array(z.enum(CONTACT_CIRCLE_KEYS))
.max(CONTACT_CIRCLE_KEYS.length)
.nullable()
.optional(),
contactForm: z.enum(CONTACT_FORM_KEYS).nullable().optional(),
contactExtent: z.enum(CONTACT_EXTENT_KEYS).nullable().optional(),
contactQuality: contextRating.nullable().optional(),
contactSupport: contextRating.nullable().optional(),
leisureCategories: z
.array(z.enum(LEISURE_CATEGORY_KEYS))
.max(LEISURE_CATEGORY_KEYS.length)
.nullable()
.optional(),
leisureMinutes: contextMinutes.nullable().optional(),
leisureJoy: contextRating.nullable().optional(),
leisureRecovery: contextRating.nullable().optional(),
eventType: z.enum(EVENT_TYPE_KEYS).nullable().optional(),
eventValence: z
.number()
.int()
.min(CONTEXT_VALENCE_MIN)
.max(CONTEXT_VALENCE_MAX)
.nullable()
.optional(),
// The same plausibility bound the entry's own instant carries: no future
// instants beyond the clock-skew tolerance, nothing before 1900.
eventAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
)
.nullable()
.optional(),
// Capped like the entry note beside it. One note for the whole context.
note: z.string().max(500).nullable().optional(),
});
export const createMoodEntrySchema = z
.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
// v1.8.5 — structured-tag keys from the taxonomy. Server resolves each
// key to a `MoodTag` row and writes the `MoodEntryTagLink` join;
// unknown keys are dropped silently (the catalog is the source of
// truth, a stale client can't mint a tag).
tagKeys: structuredTagKeys.optional(),
// v1.12.0 — rated factors scored 1..5 (or the factor's own scale).
// Parallel to the binary `tagKeys`; persisted on
// `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are
// rejected (422) / dropped server-side per the catalog.
ratedFactors: ratedFactors.optional(),
// v1.37 — the five level-A values the detail sliders capture. Three
// states, and they mean three different things, because this create can
// land on the `update:` arm of an `externalId` upsert:
// absent — nothing to say; a stored answer is left alone
// number — the answer, stored literally
// null — take the answer back
// `a1` is the exception: absent or null, the server derives it from
// `mood`, so a one-tap check-in is a full entry and never a degraded one.
a1: levelADimension.nullable().optional(),
a2: levelADimension.nullable().optional(),
a3: levelADimension.nullable().optional(),
a4: levelADimension.nullable().optional(),
a5: levelADimension.nullable().optional(),
// v1.4.30 H-5 — first-class free-text note. Replaces the
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
note: z.string().max(500).optional(),
// v1.38 — the day's context. Absent leaves an existing context alone on
// the `update:` arm of an `externalId` upsert, for the same reason A2 to
// A5 are left alone: a phone re-posting an entry from a build that has no
// context surface must not blank one somebody filled in on the web. A
// context that says nothing removes the row.
context: moodContextSchema.nullable().optional(),
// v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no
// future instants beyond a 5-min clock-skew tolerance, no instant before
// 1900. Mirrors the measurement + medication-intake bound.
moodLoggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
),
source: moodSourceEnum.optional().default("MANUAL"),
// v1.12.1 — optional source-stable id (e.g. an iOS SwiftData row UUID).
// When present, the create upserts on `(userId, source, externalId)`
// so a re-post with the same id updates the existing row in place
// instead of either 409-ing or minting a duplicate — the idempotent
// re-import iOS drives over Bearer. NULL keeps the legacy
// `(userId, date, moodLoggedAt)` behaviour. Bound matches the bulk
// `externalId` so one path can't accept an id the other rejects.
externalId: z.string().min(1).max(120).optional(),
})
// The upsert key is only idempotent while the id is stable; an id that
// rotates per client launch mints a fresh entry on every re-post. The
// bulk twin carries the same rule per entry (it cannot fail the whole
// batch on one bad row).
.superRefine(assertStableExternalId);
export const updateMoodEntrySchema = z.object({
mood: moodLevelEnum.optional(),
tags: z.array(z.string().max(50)).max(20).nullable().optional(),
// v1.8.5 — full replacement of the structured-tag set when present.
// `null` clears every link; omit to leave links untouched.
tagKeys: structuredTagKeys.nullable().optional(),
// v1.12.0 — full replacement of the rated-factor set when present.
// `null` clears every rated link; omit to leave them untouched.
ratedFactors: ratedFactors.nullable().optional(),
// v1.37 — level-A values on the edit path. Omitted leaves the stored value
// alone; an explicit `null` clears it, which is how a user takes back an
// answer they did not mean to give. Editing the five-point label without
// sending `a1` re-derives A1, the same way it re-derives `score`.
a1: levelADimension.nullable().optional(),
a2: levelADimension.nullable().optional(),
a3: levelADimension.nullable().optional(),
a4: levelADimension.nullable().optional(),
a5: levelADimension.nullable().optional(),
note: z.string().max(500).nullable().optional(),
// v1.38 — full replacement of the context when present, matching the way
// `tagKeys` and `ratedFactors` work on this path: omit to leave it alone,
// send it to replace it, send `null` (or an empty one) to remove the row.
context: moodContextSchema.nullable().optional(),
// v1.17 W1b — same plausibility bound on the edit path.
moodLoggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
).optional(),
});
export const listMoodEntriesSchema = z.object({
mood: moodLevelEnum.optional(),
// v1.15.13 — source filter for the management list. Validated against
// the mood source set; threaded into the list `where` (the `userId` +
// `deletedAt: null` pins stay). Backed by the `(userId, source,
// moodLoggedAt)` index (migration 0136).
source: moodSourceEnum.optional(),
from: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
to: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
offset: z.coerce.number().int().min(0).optional().default(0),
sortBy: z
.enum(["date", "mood", "score", "moodLoggedAt", "source"])
.optional()
.default("moodLoggedAt"),
sortDir: z.enum(["asc", "desc"]).optional().default("desc"),
});
export type CreateMoodEntryInput = z.infer<typeof createMoodEntrySchema>;
export type UpdateMoodEntryInput = z.infer<typeof updateMoodEntrySchema>;
export type ListMoodEntriesInput = z.infer<typeof listMoodEntriesSchema>;
export type MoodContextRequest = z.infer<typeof moodContextSchema>;