mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge work/contract-touchups score-config registration
This commit is contained in:
commit
a830e8a9d3
3 files changed
+460
No files matched your search
@@ -4796,6 +4796,71 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/health-score-config:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Read which pillars count toward the Health Score
|
||||
description: "v1.35.0 — the resolved composition: which pillars count, which the person took out, whether they have
|
||||
chosen at all, and the recipe version. An account that never chose reads every pillar with `hasSelection: false`
|
||||
and `version: 0`, which is exactly what its number was already built from — there is no backfill because there
|
||||
is nothing to back-fill."
|
||||
responses:
|
||||
"200":
|
||||
description: The resolved composition. `updatedAt` is absent until a selection has been stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HealthScoreConfigEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
patch:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Choose which pillars count toward the Health Score
|
||||
description: "Replaces the recipe with the given positive selection and bumps the per-user recipe version by one. The
|
||||
write is refused when the selection cannot produce a score: at least one physical measurement, and at least
|
||||
three distinct areas of health. Blood pressure, glucose and cholesterol are three pillars but one area, which is
|
||||
the refusal that surprises people. Accepting the write also evicts the account's score caches, so the next read
|
||||
reflects the new composition rather than the previous hour's number. Optimistic concurrency: send
|
||||
`baseUpdatedAt` from a prior read and the write 409s if the recipe moved since; omit it for the unconditional
|
||||
write. Rate-limited to 60 writes per minute per user."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PatchHealthScoreConfigRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: The stored composition, echoed back with the incremented `version` and the advanced `updatedAt` token.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PatchHealthScoreConfigEnvelope"
|
||||
"401": *a1
|
||||
"409":
|
||||
description: Score selection changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read
|
||||
the resource, re-apply the user's change against the fresh state, and resend with the new token.
|
||||
`meta.errorCode` = `health_score_config_conflict`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422":
|
||||
description: "The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases:
|
||||
`health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is
|
||||
`measured_physiological_domain_required` or `three_domains_required`, and the `error` string is the sentence
|
||||
to show the person); `health_score_config.invalid` — the body failed validation, with the per-issue list
|
||||
under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null`
|
||||
included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at
|
||||
all returns the bare message `health-score-config.body.invalid_json` with no errorCode."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": *a4
|
||||
/api/auth/me/modules:
|
||||
get:
|
||||
tags:
|
||||
@@ -13842,6 +13907,44 @@ components:
|
||||
description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130,
|
||||
postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference
|
||||
only — never inferred from a value, never a diagnosis.
|
||||
PatchHealthScoreConfigRequest:
|
||||
type: object
|
||||
properties:
|
||||
pillars:
|
||||
maxItems: 8
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ScorePillarId"
|
||||
description: The pillars that should count — the positive selection, not the exclusions. Replaces the stored recipe
|
||||
whole; it never merges. An id outside the catalogue is a 422 rather than being dropped, because a client has
|
||||
no business sending one.
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. A
|
||||
present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that
|
||||
this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as
|
||||
an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it."
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
required:
|
||||
- pillars
|
||||
additionalProperties: false
|
||||
description: "The pillars that should count toward the score. Strict: any key other than `pillars` and `baseUpdatedAt`
|
||||
is rejected."
|
||||
ScorePillarId:
|
||||
type: string
|
||||
enum:
|
||||
- BLOOD_PRESSURE
|
||||
- GLYCAEMIA
|
||||
- ACTIVITY
|
||||
- SLEEP
|
||||
- ADIPOSITY
|
||||
- WELLBEING
|
||||
- FITNESS
|
||||
- LIPIDS
|
||||
description: A Health Score pillar id.
|
||||
ModulePrefsPatchRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -23458,6 +23561,89 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
HealthScoreConfigEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/HealthScoreConfigResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
HealthScoreConfigResponse:
|
||||
type: object
|
||||
properties:
|
||||
pillars:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ScorePillarId"
|
||||
description: Registry-ordered pillars that count toward the score. Data availability narrows this further at score time,
|
||||
and that narrowing is not a choice — so this is the composition, not a promise that every pillar has a
|
||||
number today.
|
||||
excludedPillars:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ScorePillarId"
|
||||
description: Registry-ordered pillars the person took out.
|
||||
hasSelection:
|
||||
type: boolean
|
||||
description: "True once a selection has been written. It says the person chose, not that their choice differs from the
|
||||
default: an account that opened the surface and kept every pillar has a selection. False means nobody ever
|
||||
chose, and such an account still inherits a later change of defaults."
|
||||
version:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Per-user recipe version, monotonic and incremented on every accepted write. 0 while no selection has been
|
||||
written. A score record names the version that produced it, so a comparison can see the recipe move.
|
||||
changedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: When the recipe last changed; null while it never has.
|
||||
updatedAt:
|
||||
description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as
|
||||
`baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or
|
||||
synthesise it."
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
required:
|
||||
- pillars
|
||||
- excludedPillars
|
||||
- hasSelection
|
||||
- version
|
||||
- changedAt
|
||||
additionalProperties: false
|
||||
description: The resolved Health Score composition. `updatedAt` is ABSENT while the account has never written a
|
||||
selection — there is nothing to guard a write against, so the first save is the unconditional write.
|
||||
PatchHealthScoreConfigEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/HealthScoreConfigResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
GetModulesResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -63,6 +63,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import type { ScorePillarId } from "@/lib/analytics/score/types";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -397,3 +398,145 @@ describe("PATCH — envelope and transport", () => {
|
||||
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.35.1 — the route shipped in v1.35.0 with a settings surface in front
|
||||
* of it and no published contract at all: `grep -c health-score-config
|
||||
* docs/api/openapi.yaml` returned 0. This block is the second end.
|
||||
*
|
||||
* It does not compare the spec against a restatement of the spec. It drives
|
||||
* the REAL route to produce each response, reads the codes and keys off the
|
||||
* bytes it actually returns, and asserts the published contract names them.
|
||||
* A response the route can emit but the contract does not describe fails
|
||||
* here, which is the failure the release itself did not catch.
|
||||
*/
|
||||
describe("the published contract matches what the route serves", () => {
|
||||
const PATH = "/api/auth/me/health-score-config";
|
||||
|
||||
function doc() {
|
||||
return buildOpenApiDocument() as {
|
||||
paths?: Record<
|
||||
string,
|
||||
Record<string, { responses?: Record<string, { description?: string }> }>
|
||||
>;
|
||||
components?: {
|
||||
schemas?: Record<
|
||||
string,
|
||||
{ properties?: Record<string, unknown>; required?: string[] }
|
||||
>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Every `meta.errorCode` the PATCH route can actually produce. */
|
||||
async function emittedErrorCodes(): Promise<Map<number, Set<string>>> {
|
||||
const byStatus = new Map<number, Set<string>>();
|
||||
const record = async (body: unknown, conflict = false) => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({
|
||||
count: conflict ? 0 : 1,
|
||||
} as never);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch(body),
|
||||
);
|
||||
const env = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
if (env.meta?.errorCode) {
|
||||
const set = byStatus.get(res.status) ?? new Set<string>();
|
||||
set.add(env.meta.errorCode);
|
||||
byStatus.set(res.status, set);
|
||||
}
|
||||
};
|
||||
|
||||
// Too narrow, both reasons.
|
||||
await record({ pillars: ["ACTIVITY"] });
|
||||
await record({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] });
|
||||
// Body that fails validation.
|
||||
await record({ pillars: ["NOT_A_PILLAR"] });
|
||||
// Present-but-unparseable base token.
|
||||
await record({ pillars: ALL_EIGHT, baseUpdatedAt: null });
|
||||
// Stale base token.
|
||||
await record(
|
||||
{ pillars: ALL_EIGHT, baseUpdatedAt: "2026-07-24T08:00:00.000Z" },
|
||||
true,
|
||||
);
|
||||
return byStatus;
|
||||
}
|
||||
|
||||
it("registers both verbs", () => {
|
||||
const path = doc().paths?.[PATH];
|
||||
expect(
|
||||
path,
|
||||
`${PATH} is not registered in the OpenAPI document`,
|
||||
).toBeDefined();
|
||||
expect(Object.keys(path ?? {}).sort()).toEqual(["get", "patch"]);
|
||||
});
|
||||
|
||||
it("names every errorCode the route can emit, on the right status", async () => {
|
||||
const emitted = await emittedErrorCodes();
|
||||
const responses = doc().paths?.[PATH]?.patch?.responses ?? {};
|
||||
|
||||
// Sanity: the driver really did produce the codes, so a contract that
|
||||
// named none of them could not pass by the loop never running.
|
||||
expect([...(emitted.get(422) ?? [])].sort()).toEqual([
|
||||
"health_score_config.invalid",
|
||||
"health_score_config.too_narrow",
|
||||
"invalid_base_updated_at",
|
||||
]);
|
||||
expect([...(emitted.get(409) ?? [])]).toEqual([
|
||||
"health_score_config_conflict",
|
||||
]);
|
||||
|
||||
for (const [status, codes] of emitted) {
|
||||
const described = responses[String(status)]?.description ?? "";
|
||||
for (const code of codes) {
|
||||
expect(
|
||||
described,
|
||||
`${status} description does not mention \`${code}\``,
|
||||
).toContain(code);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes exactly the fields a real response carries", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_EIGHT }),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const schema = doc().components?.schemas?.["HealthScoreConfigResponse"];
|
||||
expect(schema, "HealthScoreConfigResponse is not emitted").toBeDefined();
|
||||
|
||||
expect(Object.keys(schema?.properties ?? {}).sort()).toEqual(
|
||||
Object.keys(env.data).sort(),
|
||||
);
|
||||
// `updatedAt` is absent on a never-configured GET, so it must not be
|
||||
// required — a generated decoder would reject that read.
|
||||
expect(schema?.required ?? []).not.toContain("updatedAt");
|
||||
});
|
||||
|
||||
it("leaves updatedAt off the read that genuinely has none", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request(`http://localhost${PATH}`),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
const published = Object.keys(
|
||||
doc().components?.schemas?.["HealthScoreConfigResponse"]?.properties ??
|
||||
{},
|
||||
);
|
||||
|
||||
expect(Object.keys(env.data)).not.toContain("updatedAt");
|
||||
for (const key of Object.keys(env.data)) expect(published).toContain(key);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
|
||||
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
|
||||
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types";
|
||||
import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape";
|
||||
import {
|
||||
dataEnvelope,
|
||||
@@ -201,6 +202,72 @@ const coachPrefsPutRequest = coachPrefsSchema
|
||||
"Coach prompt-tuning preferences to persist, plus the optional optimistic-concurrency `baseUpdatedAt` token. The guarded write covers both `coachPrefsJson` and the mirrored `insightsExcludeMetrics` column atomically. Omit the token for the legacy unconditional write.",
|
||||
});
|
||||
|
||||
// v1.35.0 — which pillars count toward the Health Score. Shipped with a
|
||||
// settings surface in front of it and no published contract at all; the
|
||||
// entry below is that contract, read off the route rather than inferred
|
||||
// from a neighbour.
|
||||
//
|
||||
// The asymmetry is the thing to get right: the REQUEST speaks the positive
|
||||
// selection (the pillars that should count, which is what a person picks),
|
||||
// while the RESPONSE carries both sides resolved. The column itself stores
|
||||
// the complement, but that is storage and no client ever sees it.
|
||||
|
||||
const scorePillarId = z
|
||||
.enum(SCORE_PILLAR_IDS)
|
||||
.meta({ id: "ScorePillarId", description: "A Health Score pillar id." });
|
||||
|
||||
const healthScoreConfigResponse = z
|
||||
.object({
|
||||
pillars: z
|
||||
.array(scorePillarId)
|
||||
.describe(
|
||||
"Registry-ordered pillars that count toward the score. Data availability narrows this further at score time, and that narrowing is not a choice — so this is the composition, not a promise that every pillar has a number today.",
|
||||
),
|
||||
excludedPillars: z
|
||||
.array(scorePillarId)
|
||||
.describe("Registry-ordered pillars the person took out."),
|
||||
hasSelection: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True once a selection has been written. It says the person chose, not that their choice differs from the default: an account that opened the surface and kept every pillar has a selection. False means nobody ever chose, and such an account still inherits a later change of defaults.",
|
||||
),
|
||||
version: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Per-user recipe version, monotonic and incremented on every accepted write. 0 while no selection has been written. A score record names the version that produced it, so a comparison can see the recipe move.",
|
||||
),
|
||||
changedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("When the recipe last changed; null while it never has."),
|
||||
updatedAt: updatedAtTokenField,
|
||||
})
|
||||
.meta({
|
||||
id: "HealthScoreConfigResponse",
|
||||
description:
|
||||
"The resolved Health Score composition. `updatedAt` is ABSENT while the account has never written a selection — there is nothing to guard a write against, so the first save is the unconditional write.",
|
||||
});
|
||||
|
||||
const healthScoreConfigPatchRequest = z
|
||||
.object({
|
||||
pillars: z
|
||||
.array(scorePillarId)
|
||||
.max(SCORE_PILLAR_IDS.length)
|
||||
.describe(
|
||||
"The pillars that should count — the positive selection, not the exclusions. Replaces the stored recipe whole; it never merges. An id outside the catalogue is a 422 rather than being dropped, because a client has no business sending one.",
|
||||
),
|
||||
baseUpdatedAt: baseUpdatedAtField,
|
||||
})
|
||||
// Mirrors the runtime `.strict()`, so a generated client is told about
|
||||
// the rejection rather than only being told in prose.
|
||||
.strict()
|
||||
.meta({
|
||||
id: "PatchHealthScoreConfigRequest",
|
||||
description:
|
||||
"The pillars that should count toward the score. Strict: any key other than `pillars` and `baseUpdatedAt` is rejected.",
|
||||
});
|
||||
|
||||
// v1.28-era — the owner's saved doctor-report selection (leaf list plus the
|
||||
// render choices the export panel restores). `v` is the report-selection
|
||||
// schema version (currently locked at 2); `leaves` is a closed catalogue of
|
||||
@@ -589,6 +656,70 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/health-score-config": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "Read which pillars count toward the Health Score",
|
||||
description:
|
||||
"v1.35.0 — the resolved composition: which pillars count, which the person took out, whether they have chosen at all, and the recipe version. An account that never chose reads every pillar with `hasSelection: false` and `version: 0`, which is exactly what its number was already built from — there is no backfill because there is nothing to back-fill.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The resolved composition. `updatedAt` is absent until a selection has been stored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
healthScoreConfigResponse,
|
||||
"HealthScoreConfigEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
summary: "Choose which pillars count toward the Health Score",
|
||||
description:
|
||||
"Replaces the recipe with the given positive selection and bumps the per-user recipe version by one. The write is refused when the selection cannot produce a score: at least one physical measurement, and at least three distinct areas of health. Blood pressure, glucose and cholesterol are three pillars but one area, which is the refusal that surprises people. Accepting the write also evicts the account's score caches, so the next read reflects the new composition rather than the previous hour's number. Optimistic concurrency: send `baseUpdatedAt` from a prior read and the write 409s if the recipe moved since; omit it for the unconditional write. Rate-limited to 60 writes per minute per user.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: healthScoreConfigPatchRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The stored composition, echoed back with the incremented `version` and the advanced `updatedAt` token.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
healthScoreConfigResponse,
|
||||
"PatchHealthScoreConfigEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...conflictResponse409(
|
||||
"Score selection",
|
||||
"health_score_config_conflict",
|
||||
),
|
||||
...stdResponses,
|
||||
// Four distinct 422s on one route, so this one is written out
|
||||
// rather than reusing the shared base-token response: a client
|
||||
// branching on `meta.errorCode` needs to see all of them, and a
|
||||
// description that named only the token would be a contract that
|
||||
// quietly omits the refusal a person actually hits.
|
||||
"422": {
|
||||
description:
|
||||
"The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases: `health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is `measured_physiological_domain_required` or `three_domains_required`, and the `error` string is the sentence to show the person); `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message `health-score-config.body.invalid_json` with no errorCode.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/modules": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
Reference in new issue
Block a user