chore(release): v1.28.44 — loose ends: broken link, API contract, module guards

From the campaign's dead-code + code-health audits (grep-verified; the
custom-metrics query-key finding was verified-false and excluded):

- sleep-overview empty-state CTA linked to /settings/devices, not a valid
  settings slug (dynamicParams=false → 404). Repointed to
  /settings/integrations (where every device OAuth callback already lands).
- six live, iOS-consumed routes were absent from docs/api/openapi.yaml —
  auth/check-user, insights/cards, integrations/healthkit (GET+PATCH),
  measurement-categories, medications/intake/bulk, mood-entries/bulk —
  registered in the Zod OpenAPI registry mirroring each handler's real shape.
- mood/labs/medications module pages lacked the redirect-when-off self-guard
  their siblings have; added, mirroring cycle/achievements exactly.

No migration. Verifier REJECTED the custom-metrics bare-key finding (they are
invalidateQueries prefix filters, not colliding useQuery keys).
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 04:59:27 +02:00
1 parent 8b6024f0d7
commit cd0fbddd1d
17 files changed
+1433 -16

No files matched your search

+13 -3
View File
@@ -49,6 +49,13 @@ export default function LabsPage() {
const [scanOpen, setScanOpen] = useState(false);
const ocrCapability = useOcrCapability(isAuthenticated);
// Gated on the resolved `modules.labs` flag from `GET /api/auth/me` (the
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
// absent key reads as enabled, so a direct URL hit only bounces on an
// explicit `false`. Every `/api/labs/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
const enabled = user?.modules?.labs !== false;
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
[queryClient],
@@ -59,12 +66,15 @@ export default function LabsPage() {
});
useEffect(() => {
if (!isLoading && !isAuthenticated) {
if (isLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!enabled) {
router.push("/");
}
}, [isLoading, isAuthenticated, router]);
}, [isLoading, isAuthenticated, enabled, router]);
if (!mounted || isLoading) {
if (!mounted || isLoading || (isAuthenticated && !enabled)) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
+14 -1
View File
@@ -153,6 +153,12 @@ function MedicationCardSkeleton() {
export default function MedicationsPage() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
// turned off the whole page disappears (the nav entry is hidden by the same
// gate) and the list query never fires (the API would 403 anyway).
// Default-on: an absent key reads as enabled, so the page only hides on an
// explicit `false`.
const medicationsEnabled = user?.modules?.medications !== false;
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
@@ -195,7 +201,7 @@ export default function MedicationsPage() {
queryFn: async () => {
return apiGet<Medication[]>("/api/medications");
},
enabled: isAuthenticated,
enabled: isAuthenticated && medicationsEnabled,
// v1.16.12 (#316) — refetch on every mount, even inside the global
// staleTime window. App Router unmounts this page on navigation away
// and remounts it on return; without this, a return within 5 min
@@ -277,6 +283,13 @@ export default function MedicationsPage() {
);
}
// Module off ⇒ the surface disappears entirely. The nav entry is hidden by
// the same gate, so a direct hit renders nothing rather than an orphaned
// shell.
if (isAuthenticated && !medicationsEnabled) {
return null;
}
if (!isAuthenticated) {
return (
<div className="space-y-6">
+14 -4
View File
@@ -19,7 +19,7 @@ import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
export default function MoodPage() {
const { isAuthenticated, isLoading } = useAuth();
const { user, isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -30,6 +30,13 @@ export default function MoodPage() {
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const { t } = useTranslations();
// Gated on the resolved `modules.mood` flag from `GET /api/auth/me` (the
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
// absent key reads as enabled, so a direct URL hit only bounces on an
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
const enabled = user?.modules?.mood !== false;
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
// whatever this page currently has mounted (`type: "active"` scopes the
// invalidation to visible queries). Suspended while the add-sheet is
@@ -44,17 +51,20 @@ export default function MoodPage() {
});
useEffect(() => {
if (!isLoading && !isAuthenticated) {
if (isLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!enabled) {
router.push("/");
}
}, [isLoading, isAuthenticated, router]);
}, [isLoading, isAuthenticated, enabled, router]);
// `!mounted` keeps the hydration render identical to the SSR HTML: the
// auth query is fired by the early-hydrating shell and can settle before
// this page boundary hydrates, so `isLoading` alone flipped the branch
// and React logged hydration error #418 — same fix as the measurements
// page; see `useMounted`.
if (!mounted || isLoading) {
if (!mounted || isLoading || (isAuthenticated && !enabled)) {
return <PageAuthGate label={t("common.loading")} />;
}
@@ -0,0 +1,39 @@
/**
* The sleep-overview empty-state CTA must point at a REAL settings slug.
* It previously linked to `/settings/devices`, which is not in
* `SETTINGS_SECTION_SLUGS`, so the dynamic `[section]` route (dynamicParams
* = false) calls `notFound()` → a hard 404 dead-end. This pins the href to
* the `integrations` slug (where every device OAuth callback lands) and
* guards against a regression to any non-existent slug.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { isSettingsSectionSlug } from "@/components/settings/section-slugs";
describe("sleep-overview empty-state CTA href", () => {
it("links to /settings/integrations (a real settings slug)", () => {
const source = readFileSync(
join(process.cwd(), "src/components/insights/sleep-overview.tsx"),
"utf8",
);
const match = source.match(/href="\/settings\/([a-z-]+)"/);
expect(
match,
"expected a /settings/<slug> href in the sleep overview",
).not.toBeNull();
const slug = match![1];
expect(slug).toBe("integrations");
expect(isSettingsSectionSlug(slug)).toBe(true);
});
it("never links to the non-existent /settings/devices slug", () => {
const source = readFileSync(
join(process.cwd(), "src/components/insights/sleep-overview.tsx"),
"utf8",
);
expect(source).not.toContain("/settings/devices");
});
});
+4 -4
View File
@@ -66,9 +66,9 @@ function SleepStageStackedBar(
*
* Empty state: no SLEEP_DURATION rows yet → render the "Apple Health
* sync is coming in v1.5" message with a deep-link to Settings →
* Devices so the user can prepare. We do NOT render any of the three
* blocks in that case — half-rendered empty cards would be worse than
* a single clean CTA.
* Integrations so the user can connect a sleep source. We do NOT render
* any of the three blocks in that case — half-rendered empty cards would
* be worse than a single clean CTA.
*/
interface SleepAnalyticsSummary {
@@ -111,7 +111,7 @@ export function SleepOverview() {
description={t("insights.sleep.empty.description")}
action={
<Button size="sm" variant="outline" asChild>
<Link href="/settings/devices">
<Link href="/settings/integrations">
{t("insights.sleep.empty.action")}
</Link>
</Button>
+60
View File
@@ -241,7 +241,67 @@ const successFlagResponse = z
.object({ success: z.boolean() })
.meta({ id: "AuthSuccessFlagResponse" });
// ── iOS onboarding discovery (check-user) ────────────────────────────
const checkUserRequest = z
.object({
identifier: z
.string()
.trim()
.min(1)
.max(254)
.describe(
"The typed identifier — either an email or a username. Queried verbatim (no case-folding), never echoed back.",
),
})
.meta({
id: "CheckUserRequest",
description:
"Discovery lookup for the iOS onboarding flow: given a typed email or username, resolve the next sign-in step.",
});
const checkUserResponse = z
.object({
branch: z
.enum(["not_found", "passkey_only", "email_fallback", "exists"])
.describe(
"Next UX step: `not_found` (show sign-up), `passkey_only` (Sign in with Passkey), `email_fallback` (password field, with a Passkey affordance when applicable), `exists` (account with no usable credential — recovery path).",
),
hasPasskey: z
.boolean()
.describe("The account has at least one registered passkey."),
hasPassword: z.boolean().describe("The account has a password hash."),
})
.meta({
id: "CheckUserResponse",
description:
"Account-existence + credential shape. The response is identical whether or not the identifier matched (account-existence is the explicit contract iOS needs); the identifier is never echoed.",
});
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/check-user": {
post: {
tags: ["Auth"],
summary: "Resolve the next sign-in step for a typed identifier",
description:
"Given an email or username, returns which onboarding branch the iOS client should render plus the `hasPasskey` / `hasPassword` booleans so it can offer a Passkey affordance alongside a password field without a second round-trip. Anonymous surface; per-IP rate-limited (30 requests / 15 min). The response is the same whether or not the identifier matched, and the identifier is never echoed back.",
requestBody: {
required: true,
content: { "application/json": { schema: checkUserRequest } },
},
responses: {
"200": {
description: "Discovery result.",
content: {
"application/json": {
schema: dataEnvelope(checkUserResponse, "CheckUserEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/auth/login": {
post: {
tags: ["Auth"],
+4
View File
@@ -43,6 +43,7 @@ import { healthRecordPaths } from "./health-record";
import { illnessPaths } from "./illness";
import { importPaths } from "./import";
import { insightsPaths } from "./insights";
import { integrationPaths } from "./integrations";
import { insightsSignalPaths } from "./insights-signals";
import { labsPaths } from "./labs";
import { ocrPaths } from "./ocr";
@@ -97,6 +98,9 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...onboardingPaths,
// v1.28 — nutrient-intake sync (appended; spread order is load-bearing).
...nutrientPaths,
// v1.28 — HealthKit integration config (appended; spread order is
// load-bearing).
...integrationPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+30 -1
View File
@@ -4,8 +4,14 @@
* Schema declarations live in `./schemas`; this module is the path orchestrator.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { consentRequiredResponse, dataEnvelope, stdResponses } from "../shared";
import {
consentRequiredResponse,
dataEnvelope,
moduleDisabledResponse,
stdResponses,
} from "../shared";
import {
insightsCardsResponse,
insightsComprehensiveResponse,
metricStatusQuery,
metricStatusResponse,
@@ -73,6 +79,29 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/insights/cards": {
get: {
tags: ["Insights"],
summary: "Insight cards (iOS adapter)",
description:
"v1.4.31 — the native-client adapter over the same alert rule engine the web comprehensive surface consumes: measurements, BP-in-target, weight trend, pulse, and cadence-aware medication compliance are fed through `generateAlerts()` and each resulting `HealthAlert` is re-shaped to the iOS Insight card model. Deterministic — no LLM call on this path. Module-gated on `insights` and the operator `insightStatus` assistant surface. Auth via cookie or Bearer.",
responses: {
"200": {
description: "The list of insight cards (possibly empty).",
content: {
"application/json": {
schema: dataEnvelope(
insightsCardsResponse,
"InsightsCardsEnvelope",
),
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
"/api/insights/pregenerate": {
post: {
tags: ["Insights"],
@@ -957,3 +957,49 @@ export const dashboardSnapshotResponse = z
description:
"Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.",
});
// v1.4.31 — the iOS "cards" adapter over the same alert rule engine the
// web comprehensive surface consumes. Each card is one `HealthAlert`
// re-shaped to the iOS Insight model. Module-gated on `insights` and the
// operator `insightStatus` assistant surface.
const insightCard = z
.object({
id: z.string().describe("Stable per-card id (e.g. `alert-1`)."),
title: z.string(),
summary: z.string().describe("One-line alert message."),
body: z
.string()
.nullable()
.describe("Longer narrative; null on the current rule-engine cards."),
severity: z
.enum(["alert", "caution", "info", "good"])
.describe(
"Mapped from the underlying alert level (danger→alert, warning→caution, success→good, else info).",
),
recommendations: z
.array(
z.object({
id: z.string(),
label: z.string(),
actionURL: z.string().nullable(),
}),
)
.describe(
"Suggested follow-ups; empty on the current rule-engine cards.",
),
generatedAt: z.iso.datetime({ offset: true }),
provider: z
.string()
.describe(
"Lower-cased AI provider label for the account (e.g. `claude`).",
),
})
.meta({
id: "InsightCard",
description:
"One iOS insight card, re-shaped from a server-side HealthAlert. Deterministic rule-engine output — no LLM call on this path.",
});
export const insightsCardsResponse = z
.array(insightCard)
.meta({ id: "InsightsCardsResponse" });
+115
View File
@@ -0,0 +1,115 @@
/**
* OpenAPI route table — third-party integration config (HealthKit).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
* Response DTOs are declared here mirroring the route handler under
* `src/app/api/integrations/healthkit/route.ts`; the request schema
* mirrors the handler's `patchSchema`.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { dataEnvelope, stdResponses } from "./shared";
// Mirror the route's `directionEnum` — per-metric sync direction.
const healthKitDirectionEnum = z
.enum(["bidirectional", "readOnly", "writeOnly", "disabled"])
.describe("Per-metric sync direction.");
// Resolved entry (defaults merged): `kind` + `enabled` are always present.
const healthKitEntry = z
.object({
id: z.string().describe("Stable metric key (e.g. `bodyMass`)."),
kind: z.string().describe("HealthKit sample kind (e.g. `bloodPressure`)."),
direction: healthKitDirectionEnum,
enabled: z.boolean(),
})
.meta({
id: "HealthKitEntry",
description:
"One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).",
});
const healthKitConfigResponse = z
.object({
entries: z.array(healthKitEntry),
lastSyncedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe(
"When HealthKit last synced for this user; null when never (and always null on the PATCH echo).",
),
})
.meta({
id: "HealthKitConfigResponse",
description:
"The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric overrides.",
});
// Mirror the route's `patchSchema` — merge-by-id; unknown ids are ignored.
const healthKitPatchEntry = z.object({
id: z.string().min(1).max(64),
kind: z.string().min(1).max(64).optional(),
direction: healthKitDirectionEnum,
enabled: z.boolean().optional(),
});
const healthKitPatchRequest = z
.object({
entries: z.array(healthKitPatchEntry).max(50),
})
.meta({
id: "HealthKitConfigPatchRequest",
description:
"Merge-by-`id` update of the HealthKit metric config. Unknown ids are silently ignored; omitted fields fall back to the stored (or default) value for that entry. Up to 50 entries per call.",
});
export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/integrations/healthkit": {
get: {
tags: ["Integrations"],
summary: "Read the HealthKit integration config",
description:
"Returns the resolved per-metric HealthKit config — the default metric set merged with the user's stored overrides — plus the last HealthKit sync instant. Auth via cookie or Bearer.",
responses: {
"200": {
description: "Resolved HealthKit config.",
content: {
"application/json": {
schema: dataEnvelope(
healthKitConfigResponse,
"HealthKitConfigEnvelope",
),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Integrations"],
summary: "Update the HealthKit integration config",
description:
"Merges the supplied entries into the stored config by `id` (unknown ids are ignored) and returns the resolved config (defaults merged) so the client always sees a complete metric list. `lastSyncedAt` is null on the echo. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: {
"application/json": { schema: healthKitPatchRequest },
},
},
responses: {
"200": {
description: "Updated + resolved HealthKit config.",
content: {
"application/json": {
schema: dataEnvelope(
healthKitConfigResponse,
"HealthKitConfigPatchEnvelope",
),
},
},
},
...stdResponses,
},
},
},
};
+121
View File
@@ -11,6 +11,7 @@ import {
intakeSchema,
createInventoryItemSchema,
updateInventoryItemSchema,
injectionSiteEnum,
} from "@/lib/validations/medication";
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import {
@@ -49,7 +50,127 @@ import {
medicationListLayoutSchema,
} from "./schemas";
// ── Bulk intake backfill (iOS SyncMode) — mirrors the route's
// `bulkPayloadSchema` / `bulkEntrySchema` and the batch-envelope response.
const bulkIntakeEntry = z
.object({
medicationId: z.string().min(1),
scheduledFor: z.iso
.datetime({ offset: true })
.optional()
.describe("ISO instant; defaults to `takenAt` then now() when omitted."),
takenAt: z.iso
.datetime({ offset: true })
.optional()
.describe("ISO instant of the take; omit + `skipped:false` = pending."),
skipped: z.boolean().optional().describe("Default false."),
idempotencyKey: z.string().min(1).max(128).optional(),
injectionSite: injectionSiteEnum
.optional()
.describe(
"Per-entry injection site; a disallowed site marks THIS entry skipped without failing the batch.",
),
forceSlotInstant: z.iso
.datetime({ offset: true })
.optional()
.describe(
"Pin a taken entry onto a named real scheduled slot; ignored on non-taken entries.",
),
doseTaken: z
.string()
.trim()
.min(1)
.max(50)
.optional()
.describe("Per-entry dose override; persisted only on a taken entry."),
source: z
.literal("APPLE_HEALTH")
.optional()
.describe(
"v1.28 — Apple Health dose-event import. Must be supplied together with `externalId`, and may only target a medication mirrored from Apple Health (else the whole batch 422s).",
),
externalId: z
.string()
.trim()
.min(1)
.max(128)
.optional()
.describe(
"The HealthKit dose-event UUID. Drives the idempotent re-sync dedup (first-write-wins per Apple dose).",
),
})
.meta({
id: "BulkMedicationIntakeEntry",
description:
"One bulk medication-intake entry. `source` + `externalId` are both-or-neither.",
});
const bulkIntakePayload = z
.object({
entries: z.array(bulkIntakeEntry).min(1).max(500),
})
.meta({
id: "BulkMedicationIntakeRequest",
description:
"iOS SyncMode bulk intake backfill. 1–500 entries per call; idempotent via `Idempotency-Key` and per-entry `idempotencyKey` / `externalId`.",
});
const bulkIntakeEntryResult = z
.object({
index: z.number().int().nonnegative(),
status: z
.enum(["inserted", "updated", "duplicate", "skipped"])
.describe(
"`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see `reason`.",
),
reason: z.string().optional(),
id: z.string().optional().describe("The landed row id (absent on skips)."),
})
.meta({ id: "BulkMedicationIntakeEntryResult" });
const bulkIntakeResponse = z
.object({
processed: z.number().int().nonnegative(),
inserted: z.number().int().nonnegative(),
updated: 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(bulkIntakeEntryResult),
})
.meta({ id: "BulkMedicationIntakeResponse" });
export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/medications/intake/bulk": {
post: {
tags: ["Medications"],
summary: "Bulk medication-intake backfill (iOS SyncMode)",
description:
"Up to 500 intake entries per call, mirroring the mood-entries bulk envelope so the iOS sync engine reuses one retry/cursor path. Idempotent via the `Idempotency-Key` header plus per-entry `idempotencyKey` / `externalId`. Per-entry status (`inserted` / `updated` / `duplicate` / `skipped`) lets the client advance its cursor. Rate-limited 60/min/user. An `APPLE_HEALTH` entry must carry `externalId` and target a medication mirrored from Apple Health, else the whole batch 422s (`meta.errorCode = medications.intake.bulk.apple_health_not_mirrored`); an over-size batch 422s (`medications.intake.bulk.too_large`); a malformed body 422s (`medications.intake.bulk.invalid`).",
requestBody: {
required: true,
content: { "application/json": { schema: bulkIntakePayload } },
},
responses: {
"200": {
description: "Batch processed (always 200 on a well-formed body).",
content: {
"application/json": {
schema: dataEnvelope(
bulkIntakeResponse,
"BulkMedicationIntakeResponseEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/medications": {
get: {
tags: ["Medications"],
+52
View File
@@ -116,7 +116,59 @@ const capabilitiesResponse = z
"Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit.",
});
// v1.4.41 — the measurement categorisation overlay iOS reads on cold start
// to drive the HealthKit permission picker (one consent screen per
// category). Every value is sourced server-side from the canonical
// `MEASUREMENT_CATEGORIES` map, so the wire shape here is the contract.
const measurementCategoriesResponse = z
.object({
version: z
.literal(1)
.describe("Additive schema marker; assignments never reshuffle."),
categories: z
.array(
z.object({
id: z.string().describe("Category id (e.g. `vitals`)."),
labelKey: z
.string()
.describe("i18n key for the category label (`categories.<id>`)."),
order: z.number().int().describe("Stable display order."),
}),
)
.describe("The ordered category list for the picker."),
assignments: z
.record(z.string(), z.string())
.describe("MeasurementType → category id map."),
})
.meta({
id: "MeasurementCategoriesResponse",
description:
"The measurement categorisation overlay: the ordered category list plus the MeasurementType → category assignments. Derived server-side from the canonical map; cached `public, max-age=600`.",
});
export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/measurement-categories": {
get: {
tags: ["Meta"],
summary: "Measurement categorisation overlay",
description:
"Returns the UI-side measurement categorisation as an HTTP contract: the ordered category list (id + i18n label key + order) plus the MeasurementType → category assignments. iOS reads this on cold start to drive the HealthKit permission picker (one consent screen per category) and caches it client-side (`Cache-Control: public, max-age=600`). Auth via cookie or Bearer (not admin); no PII.",
responses: {
"200": {
description: "Categorisation overlay.",
content: {
"application/json": {
schema: dataEnvelope(
measurementCategoriesResponse,
"MeasurementCategoriesEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/meta/capabilities": {
get: {
tags: ["Meta"],
+114
View File
@@ -23,6 +23,7 @@ import {
hideCatalogueTagSchema,
} from "@/lib/mood/custom-tags";
import { moodTagLayoutSchema } from "@/lib/mood/tag-layout";
import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/moodlog";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// ── Request schemas — annotated for spec emission ────────────────────
@@ -167,7 +168,120 @@ const notFoundResponse = {
},
} as const;
// ── 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(),
moodLoggedAt: z.iso
.datetime({ offset: true })
.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.",
),
})
.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.",
});
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."),
})
.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" });
export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/mood-entries/bulk": {
post: {
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: {
"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"],