mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Without from, GET /api/day/notable started at the last completed visit of any kind, while the web passes the next planned visit's preparation.from, so a client that omits from read a different list for the same appointment. Without from the window now starts at that preparation day (anchor nextVisit, documented in OpenAPI); with nothing booked it keeps the last completed visit or the 90-day fallback.
634 lines
21 KiB
TypeScript
634 lines
21 KiB
TypeScript
/**
|
|
* Zod schemas for the day view, the timeline and life events (v1.42, #613).
|
|
*
|
|
* The routes parse their input with these, the OpenAPI table publishes them,
|
|
* and `./contract.ts` infers its wire types from them, so the request a route
|
|
* accepts, the shape it promises and the type a client reads are one thing.
|
|
* Server and OpenAPI side only: client code imports the types from
|
|
* `./contract.ts`, never this file, so no validator lands in the browser.
|
|
*/
|
|
import { z } from "zod/v4";
|
|
|
|
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
|
|
|
|
import {
|
|
DAY_EVENT_KINDS,
|
|
DAY_INDEX_MAX_SPAN_DAYS,
|
|
DAY_NOTABLE_KINDS,
|
|
DAY_RUNNING_KINDS,
|
|
DAY_SCORE_KEYS,
|
|
DAY_SECTION_KEYS,
|
|
DAY_SECTION_UNAVAILABLE_REASONS,
|
|
LIFE_EVENT_CATEGORIES,
|
|
LIFE_EVENT_NOTE_MAX,
|
|
LIFE_EVENT_PRECISIONS,
|
|
LIFE_EVENT_TITLE_MAX,
|
|
TIMELINE_BUCKETS,
|
|
TIMELINE_ITEM_KINDS,
|
|
TIMELINE_LANE_KEYS,
|
|
TIMELINE_READINESS_KEYS,
|
|
TIMELINE_READINESS_STATUSES,
|
|
TIMELINE_READINESS_VERDICTS,
|
|
TIMELINE_RANGE_MAX_DAYS,
|
|
TIMELINE_ZOOMS,
|
|
} from "./contract";
|
|
import { isoDateTime } from "@/lib/validations/datetime";
|
|
|
|
/* ─── Shared pieces ───────────────────────────────────────────────────────── */
|
|
|
|
export const dateKeySchema = z
|
|
.string()
|
|
.refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" })
|
|
.describe("A local calendar date, `YYYY-MM-DD`.");
|
|
|
|
const instant = isoDateTime();
|
|
|
|
const messageParams = z
|
|
.record(z.string(), z.union([z.string(), z.number()]))
|
|
.describe(
|
|
"Parameters for the message the client words from its own bundle. Dates are `YYYY-MM-DD`, values are numbers in the unit the entry names.",
|
|
);
|
|
|
|
const daySectionKey = z.enum(DAY_SECTION_KEYS);
|
|
|
|
/* ─── GET /api/day/{date} ─────────────────────────────────────────────────── */
|
|
|
|
export const dayPathSchema = z.object({ date: dateKeySchema });
|
|
|
|
const dayRunningItem = z
|
|
.object({
|
|
kind: z.enum(DAY_RUNNING_KINDS),
|
|
section: daySectionKey,
|
|
id: z.string(),
|
|
title: z
|
|
.string()
|
|
.describe("Decrypted server-side for the caller's own session."),
|
|
sub: z
|
|
.string()
|
|
.nullable()
|
|
.describe("A second line, already formatted (a dose, a destination)."),
|
|
since: dateKeySchema
|
|
.nullable()
|
|
.describe(
|
|
"The record's own start date. Null when the record holds none (a medication filed without a start date); the entry's creation date is never put in its place.",
|
|
),
|
|
until: dateKeySchema.nullable().describe("Null while it is still open."),
|
|
dayIndex: z
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.nullable()
|
|
.describe(
|
|
"Day n of the period, counting `since` as day 1; null when `since` is null.",
|
|
),
|
|
dayCount: z
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.nullable()
|
|
.describe("Length of a closed period in days; null while open."),
|
|
href: z
|
|
.string()
|
|
.nullable()
|
|
.describe("In-app path to the record this came from."),
|
|
})
|
|
.meta({ id: "DayRunningItem" });
|
|
|
|
const dayValue = z
|
|
.object({
|
|
type: z.string().describe("A `MeasurementType`."),
|
|
value: z.number(),
|
|
unit: z.string(),
|
|
at: instant,
|
|
source: z
|
|
.string()
|
|
.describe("The `MeasurementSource` of the canonical row."),
|
|
band: z
|
|
.object({
|
|
lo: z.number(),
|
|
hi: z.number(),
|
|
n: z.number().int().nonnegative(),
|
|
})
|
|
.nullable()
|
|
.describe(
|
|
"The person's own usual range over the 30 days before this day, from their daily values. Null with too little history.",
|
|
),
|
|
})
|
|
.meta({ id: "DayValue" });
|
|
|
|
const dayEvent = z
|
|
.object({
|
|
at: instant
|
|
.nullable()
|
|
.describe("The instant, or null for something that has a date only."),
|
|
kind: z.enum(DAY_EVENT_KINDS),
|
|
section: daySectionKey,
|
|
id: z.string(),
|
|
title: z
|
|
.string()
|
|
.describe(
|
|
"The record's own name, or a code the client words: a mood, a screener's instrument, a workout's sport, `cycle` for a cycle day.",
|
|
),
|
|
meta: z
|
|
.string()
|
|
.nullable()
|
|
.describe(
|
|
"The second line: text (a dose, a lab value, a duration) or a code or number the client words (a category, a kind, a severity, a screener's score and band, an intensity, a dose number).",
|
|
),
|
|
note: z
|
|
.string()
|
|
.nullable()
|
|
.describe("The person's own note, decrypted for the caller's session."),
|
|
docs: z
|
|
.array(z.object({ id: z.string(), name: z.string() }))
|
|
.describe("Documents filed against the entry."),
|
|
href: z.string().nullable(),
|
|
})
|
|
.meta({ id: "DayEvent" });
|
|
|
|
const dayScore = z
|
|
.object({
|
|
key: z.enum(DAY_SCORE_KEYS),
|
|
value: z
|
|
.number()
|
|
.describe(
|
|
"The score on its own scale: whole points for a 0 to 100 score, one decimal for a device's day strain.",
|
|
),
|
|
max: z
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.describe("The top of the scale: 100, or 21 for a device's day strain."),
|
|
source: z
|
|
.string()
|
|
.describe(
|
|
"`COMPUTED` for a score the server derived, else the `MeasurementSource` of the device row.",
|
|
),
|
|
band: z
|
|
.object({
|
|
lo: z.number(),
|
|
hi: z.number(),
|
|
n: z.number().int().nonnegative(),
|
|
})
|
|
.nullable()
|
|
.describe(
|
|
"The person's own usual range of the score over the 30 days before this day, on the same scale. Null with too little history.",
|
|
),
|
|
})
|
|
.meta({ id: "DayScore" });
|
|
|
|
const dayNotable = z
|
|
.object({
|
|
kind: z.enum(DAY_NOTABLE_KINDS),
|
|
type: z.string().nullable().describe("The `MeasurementType` it is about."),
|
|
params: messageParams,
|
|
})
|
|
.meta({ id: "DayNotable" });
|
|
|
|
/**
|
|
* v1.43 — the day's movement context from Dawarich: daily values only, never
|
|
* a coordinate or a place name. The record owner's only, like the
|
|
* environment readings; a delegate never receives it.
|
|
*/
|
|
export const dayMovementSchema = z
|
|
.object({
|
|
source: z.literal("dawarich"),
|
|
distance: z.object({
|
|
unit: z
|
|
.enum(["km", "mi"])
|
|
.describe("Resolved from the account's unit preference."),
|
|
total: z.number().nullable(),
|
|
driving: z.number().nullable(),
|
|
train: z.number().nullable(),
|
|
}),
|
|
placesVisited: z.number().int().nullable(),
|
|
awayFromHomeMinutes: z
|
|
.number()
|
|
.int()
|
|
.nullable()
|
|
.describe("`null` means no coverage that day, not zero."),
|
|
travel: z
|
|
.object({
|
|
isTravelDay: z.boolean(),
|
|
countryCode: z.string().nullable(),
|
|
})
|
|
.nullable(),
|
|
fetchedAt: isoDateTime(),
|
|
})
|
|
.meta({
|
|
id: "DayMovement",
|
|
description:
|
|
"Distances to one decimal in `distance.unit`. Hide a null field; never show it as zero.",
|
|
});
|
|
|
|
export const dayResponseSchema = z
|
|
.object({
|
|
date: dateKeySchema,
|
|
tz: z.string().describe("The IANA zone the day was cut in."),
|
|
counts: z.object({
|
|
values: z.number().int().nonnegative(),
|
|
entries: z.number().int().nonnegative(),
|
|
}),
|
|
running: z.array(dayRunningItem),
|
|
values: z.array(dayValue),
|
|
events: z.array(dayEvent),
|
|
notable: z.array(dayNotable),
|
|
scores: z
|
|
.array(dayScore)
|
|
.describe(
|
|
"The scores the record holds for this day, in `DAY_SCORE_KEYS` order. A score with no value that day is absent; an empty list means none.",
|
|
),
|
|
sections: z
|
|
.partialRecord(
|
|
daySectionKey,
|
|
z.object({
|
|
available: z.literal(false),
|
|
reason: z.enum(DAY_SECTION_UNAVAILABLE_REASONS),
|
|
}),
|
|
)
|
|
.describe(
|
|
"Sections the caller cannot see. A section of a switched-off module is left out entirely rather than listed.",
|
|
),
|
|
movement: dayMovementSchema
|
|
.nullable()
|
|
.optional()
|
|
.describe(
|
|
"v1.43 — the day's movement context; absent for a delegate, with the `environment` module off, with no Dawarich connection, and on older servers.",
|
|
),
|
|
})
|
|
.meta({ id: "Day" });
|
|
|
|
/* ─── GET /api/day/index ──────────────────────────────────────────────────── */
|
|
|
|
export const dayIndexQuerySchema = z
|
|
.object({ from: dateKeySchema, to: dateKeySchema })
|
|
.refine((q) => q.from <= q.to, {
|
|
message: "`from` must not be after `to`",
|
|
path: ["to"],
|
|
})
|
|
.refine(
|
|
// Both ends as the same instant of their calendar day, so the difference
|
|
// counts calendar days whatever the process zone.
|
|
(q) =>
|
|
(dateOnlyAtNoonUtc(q.to).getTime() -
|
|
dateOnlyAtNoonUtc(q.from).getTime()) /
|
|
86_400_000 <
|
|
DAY_INDEX_MAX_SPAN_DAYS,
|
|
{
|
|
message: `At most ${DAY_INDEX_MAX_SPAN_DAYS} days`,
|
|
path: ["to"],
|
|
},
|
|
);
|
|
|
|
export const dayIndexResponseSchema = z
|
|
.object({
|
|
from: dateKeySchema,
|
|
to: dateKeySchema,
|
|
days: z
|
|
.record(z.string(), z.array(daySectionKey))
|
|
.describe(
|
|
"Each day in the window that holds anything, keyed `YYYY-MM-DD`, with the sections it holds. Days without entries are absent.",
|
|
),
|
|
notable: z
|
|
.array(z.string())
|
|
.describe("Days in the window that carry a notable observation."),
|
|
})
|
|
.meta({ id: "DayIndex" });
|
|
|
|
/* ─── GET /api/day/notable ────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* The window before the next visit: "what happened since the last one". Both
|
|
* ends are optional; the server fills `from` with the last completed visit
|
|
* and `to` with today, and says which it used.
|
|
*/
|
|
export const dayNotableQuerySchema = z
|
|
.object({ from: dateKeySchema.optional(), to: dateKeySchema.optional() })
|
|
.refine((q) => q.from === undefined || q.to === undefined || q.from <= q.to, {
|
|
message: "`from` must not be after `to`",
|
|
path: ["to"],
|
|
});
|
|
|
|
const dayChange = z
|
|
.object({
|
|
date: dateKeySchema,
|
|
kind: z.enum(DAY_EVENT_KINDS),
|
|
section: daySectionKey,
|
|
id: z.string(),
|
|
title: z
|
|
.string()
|
|
.describe("The record's own name for it, decrypted for the caller."),
|
|
count: z
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.describe("Entries folded into this one (lab results of one day)."),
|
|
href: z.string().nullable(),
|
|
})
|
|
.meta({ id: "DayChange" });
|
|
|
|
export const dayNotableResponseSchema = z
|
|
.object({
|
|
from: dateKeySchema,
|
|
to: dateKeySchema,
|
|
anchor: z
|
|
.enum(["nextVisit", "lastVisit", "requested", "fallback"])
|
|
.describe(
|
|
"Where `from` came from. `requested`: the request named it. `nextVisit`: no `from` was sent and a visit is booked; `from` is that visit's `preparation.from` on `GET /api/encounters` (the soonest PLANNED visit ahead), so a client that omits `from` reads the window the web shows for it. `lastVisit`: nothing is booked; the day of the last completed visit. `fallback`: 90 days, when the record holds no visit the caller may see. Clients should treat an unknown value like `fallback`.",
|
|
),
|
|
observations: z.array(
|
|
z
|
|
.object({
|
|
date: dateKeySchema,
|
|
kind: z.enum(DAY_NOTABLE_KINDS),
|
|
type: z.string().nullable(),
|
|
params: messageParams,
|
|
})
|
|
.meta({ id: "DatedDayNotable" }),
|
|
),
|
|
changes: z
|
|
.array(dayChange)
|
|
.describe(
|
|
"Context changes in the window, oldest first: dose changes, medication and course starts and ends, pauses, illness onsets and recoveries, vaccinations, procedures and lab days.",
|
|
),
|
|
})
|
|
.meta({ id: "DayNotableWindow" });
|
|
|
|
/* ─── GET /api/timeline ───────────────────────────────────────────────────── */
|
|
|
|
export const timelineQuerySchema = z
|
|
.object({
|
|
zoom: z
|
|
.enum(TIMELINE_ZOOMS)
|
|
.default("all")
|
|
.describe(
|
|
"`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length.",
|
|
),
|
|
from: dateKeySchema.optional(),
|
|
to: dateKeySchema.optional(),
|
|
values: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts once, so the list is bounded by the names that exist; an unknown name answers 422.",
|
|
),
|
|
})
|
|
.refine((q) => q.zoom !== "range" || (q.from && q.to), {
|
|
message: "`range` needs both `from` and `to`",
|
|
path: ["from"],
|
|
})
|
|
.refine(
|
|
(q) =>
|
|
q.zoom !== "range" ||
|
|
!q.from ||
|
|
!q.to ||
|
|
// Both ends as the same instant of their calendar day, so the
|
|
// difference counts calendar days whatever the process zone.
|
|
(dateOnlyAtNoonUtc(q.to).getTime() -
|
|
dateOnlyAtNoonUtc(q.from).getTime()) /
|
|
86_400_000 <
|
|
TIMELINE_RANGE_MAX_DAYS,
|
|
{
|
|
message: `A range spans at most ${TIMELINE_RANGE_MAX_DAYS} days`,
|
|
path: ["to"],
|
|
},
|
|
);
|
|
|
|
const timelineItem = z
|
|
.object({
|
|
id: z.string(),
|
|
kind: z.enum(TIMELINE_ITEM_KINDS),
|
|
start: dateKeySchema,
|
|
end: dateKeySchema.nullable().describe("Null for a point or an open span."),
|
|
open: z.boolean().describe("A span that has not ended."),
|
|
precision: z.enum(LIFE_EVENT_PRECISIONS),
|
|
startKnown: z
|
|
.boolean()
|
|
.describe(
|
|
"False when the start is a stand-in (a medication without a start date starts at its first intake).",
|
|
),
|
|
label: z
|
|
.string()
|
|
.describe(
|
|
"The item's name. A trip is labelled `travel` and a cycle `cycle`; the client words both.",
|
|
),
|
|
sub: z
|
|
.string()
|
|
.nullable()
|
|
.describe(
|
|
"The second line, by kind: a code for a life event (its category), a visit or procedure (its encounter kind) and a document (its kind); the analyte count for a lab day; the dose as text for a medication or a dose change. The client words every code.",
|
|
),
|
|
href: z.string().nullable(),
|
|
group: z
|
|
.string()
|
|
.nullable()
|
|
.describe(
|
|
"Items that belong to one thing share a group and are drawn on one row: a medication's own span, its courses, its dose changes and its pauses all carry the medication's id. Null for an item that stands alone.",
|
|
),
|
|
})
|
|
.meta({ id: "TimelineItem" });
|
|
|
|
const timelineStanding = z
|
|
.object({
|
|
lane: z.enum(TIMELINE_LANE_KEYS),
|
|
id: z.string(),
|
|
label: z.string(),
|
|
since: dateKeySchema.nullable(),
|
|
href: z.string().nullable(),
|
|
})
|
|
.meta({ id: "TimelineStandingItem" });
|
|
|
|
const timelineSeries = z
|
|
.object({
|
|
key: z.string().describe("A `MeasurementType` or `MOOD`."),
|
|
unit: z.string().nullable(),
|
|
points: z
|
|
.array(
|
|
z.object({
|
|
t: dateKeySchema.describe("The bucket's first day."),
|
|
mean: z
|
|
.number()
|
|
.describe(
|
|
"The mean of the bucket's day values, each day weighing one (a cumulative type's days are its day totals, a pulse-like type's its hourly-mean day value).",
|
|
),
|
|
count: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.describe(
|
|
"Readings behind the mean. Clients draw a point from fewer than three as thin.",
|
|
),
|
|
}),
|
|
)
|
|
.describe(
|
|
"Buckets with at least one reading, oldest first. A bucket without one is absent, never interpolated.",
|
|
),
|
|
})
|
|
.meta({ id: "TimelineSeries" });
|
|
|
|
export const timelineResponseSchema = z
|
|
.object({
|
|
zoom: z.enum(TIMELINE_ZOOMS),
|
|
range: z.object({
|
|
from: dateKeySchema,
|
|
to: dateKeySchema,
|
|
dataFrom: dateKeySchema
|
|
.nullable()
|
|
.describe("The earliest date anything in the record carries."),
|
|
}),
|
|
lanes: z.array(
|
|
z.object({
|
|
key: z.enum(TIMELINE_LANE_KEYS),
|
|
items: z.array(timelineItem),
|
|
}),
|
|
),
|
|
standing: z
|
|
.array(timelineStanding)
|
|
.describe("Things without a start: shown once as standing."),
|
|
bucket: z
|
|
.enum(TIMELINE_BUCKETS)
|
|
.describe(
|
|
"The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month` from 120 days, `week` from 42 days, `day` below.",
|
|
),
|
|
series: z.array(timelineSeries),
|
|
availableSeries: z
|
|
.array(z.string())
|
|
.describe(
|
|
"Every value series this record can show, whatever the window: the `MeasurementType` names with live readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has entries. Each may be named in `values`.",
|
|
),
|
|
notable: z.array(
|
|
z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }),
|
|
),
|
|
})
|
|
.meta({ id: "Timeline" });
|
|
|
|
/* ─── GET /api/timeline/readiness ─────────────────────────────────────────── */
|
|
|
|
export const timelineReadinessResponseSchema = z
|
|
.object({
|
|
verdict: z.enum(TIMELINE_READINESS_VERDICTS),
|
|
since: dateKeySchema
|
|
.nullable()
|
|
.describe("The earliest date the carrying lanes reach back to."),
|
|
lanes: z.array(
|
|
z.object({
|
|
key: z.enum(TIMELINE_READINESS_KEYS),
|
|
status: z.enum(TIMELINE_READINESS_STATUSES),
|
|
count: z.number().int().nonnegative(),
|
|
detail: z
|
|
.object({ key: z.string(), params: messageParams })
|
|
.nullable()
|
|
.describe("Worded by the client from `timeline.readiness.detail`."),
|
|
gaps: z.array(
|
|
z.object({
|
|
key: z.string(),
|
|
count: z.number().int().nonnegative(),
|
|
href: z.string(),
|
|
}),
|
|
),
|
|
}),
|
|
),
|
|
})
|
|
.meta({ id: "TimelineReadiness" });
|
|
|
|
/* ─── /api/life-events ────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* At MONTH precision a date is the first of its month, at YEAR the first of
|
|
* its year. Storing the stand-in day keeps the column sortable and the rule
|
|
* keeps two spellings of "September 2023" from existing.
|
|
*/
|
|
function alignedToPrecision(
|
|
date: string,
|
|
precision: (typeof LIFE_EVENT_PRECISIONS)[number],
|
|
): boolean {
|
|
if (precision === "MONTH") return date.endsWith("-01");
|
|
if (precision === "YEAR") return date.endsWith("-01-01");
|
|
return true;
|
|
}
|
|
|
|
const lifeEventTitle = z.string().trim().min(1).max(LIFE_EVENT_TITLE_MAX);
|
|
const lifeEventNote = z.string().trim().max(LIFE_EVENT_NOTE_MAX);
|
|
|
|
export const lifeEventSchema = z
|
|
.object({
|
|
id: z.string(),
|
|
category: z.enum(LIFE_EVENT_CATEGORIES),
|
|
startDate: dateKeySchema,
|
|
endDate: dateKeySchema.nullable(),
|
|
precision: z.enum(LIFE_EVENT_PRECISIONS),
|
|
title: z
|
|
.string()
|
|
.nullable()
|
|
.describe("Decrypted; null only when the stored title cannot be read."),
|
|
note: z.string().nullable(),
|
|
createdAt: instant,
|
|
updatedAt: instant,
|
|
})
|
|
.meta({
|
|
id: "LifeEvent",
|
|
description:
|
|
"A dated anchor in the person's life. Shared under the `profile` domain; never sent to a model.",
|
|
});
|
|
|
|
export const lifeEventListResponseSchema = z
|
|
.object({ events: z.array(lifeEventSchema) })
|
|
.meta({ id: "LifeEventList" });
|
|
|
|
export const lifeEventCreateSchema = z
|
|
.object({
|
|
category: z.enum(LIFE_EVENT_CATEGORIES),
|
|
startDate: dateKeySchema,
|
|
endDate: dateKeySchema.nullable().optional(),
|
|
precision: z.enum(LIFE_EVENT_PRECISIONS),
|
|
title: lifeEventTitle,
|
|
note: lifeEventNote.nullable().optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
if (!alignedToPrecision(value.startDate, value.precision)) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
path: ["startDate"],
|
|
message: "The date must be the first day of its month or year",
|
|
});
|
|
}
|
|
if (value.endDate != null) {
|
|
if (!alignedToPrecision(value.endDate, value.precision)) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
path: ["endDate"],
|
|
message: "The date must be the first day of its month or year",
|
|
});
|
|
}
|
|
if (value.endDate < value.startDate) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
path: ["endDate"],
|
|
message: "`endDate` must not be before `startDate`",
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.meta({ id: "CreateLifeEventRequest" });
|
|
|
|
/**
|
|
* A partial edit. The cross-field rules are checked against the stored row,
|
|
* because a precision change alone can misalign a date the request does not
|
|
* carry; the route merges and re-checks with `lifeEventCreateSchema`.
|
|
*/
|
|
export const lifeEventUpdateSchema = z
|
|
.object({
|
|
category: z.enum(LIFE_EVENT_CATEGORIES),
|
|
startDate: dateKeySchema,
|
|
endDate: dateKeySchema.nullable(),
|
|
precision: z.enum(LIFE_EVENT_PRECISIONS),
|
|
title: lifeEventTitle,
|
|
note: lifeEventNote.nullable(),
|
|
})
|
|
.partial()
|
|
.strict()
|
|
.meta({ id: "UpdateLifeEventRequest" });
|
|
|
|
export const lifeEventIdPathSchema = z.object({ id: z.string() });
|