Merge branch 'feat/v144-userneeds' into release/v1.44.0

# Conflicts:
#	src/__tests__/sharing-surface-guard.test.ts
#	src/__tests__/workout-canonical-identity-guard.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:24:01 +02:00
commit aa899a9d54
86 files changed
+12949 -9

No files matched your search

+9 -1
View File
@@ -40,6 +40,7 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
*/
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
{
activity: ["activity.calendar.future_date", "activity.calendar.too_old"],
ai: [
"ai.budget.exceeded",
"ai.provider.none",
@@ -225,7 +226,14 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"illness.episode.parent-not-found",
"illness.insights.invalid",
],
import: ["import.apple_health.busy", "import.write_failed"],
import: [
"import.apple_health.busy",
"import.csv.content_type",
"import.csv.empty",
"import.csv.too_large",
"import.rate_limited",
"import.write_failed",
],
ingest: ["ingest.rate_limited"],
insights: [
"insights.generate.budgetExceeded",
+102
View File
@@ -0,0 +1,102 @@
/**
* OpenAPI route table — the activity calendar and activity goals (D1036).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The schemas
* are the runtime ones from `@/lib/validations/activity-*`.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import {
activityCalendarQuerySchema,
activityCalendarResponseSchema,
activityGoalsResponseSchema,
} from "@/lib/validations/activity-calendar";
import { activityGoalsSchema } from "@/lib/validations/activity-goals";
import {
dataEnvelope,
MODULE_DISABLED_DESCRIPTION,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
const activityGoalsPatch = activityGoalsSchema.meta({
id: "ActivityGoalsPatch",
description:
"The goals to change. A key that is present replaces the stored goal (`null` clears it); a key that is absent stays as it is. Unknown keys are refused.",
});
export const activityPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/activity/calendar": {
get: {
tags: ["Measurements"],
summary: "Activity calendar against the person's own normal",
description:
"A week (Monday to Sunday) or a month of activity, computed server-side without AI: per local day the step total (the canonical-source sum the steps chart draws), active minutes (the larger of exercise minutes and workout minutes), workouts by sport after the cross-source collapse, and a training-load score when one exists. Each day is placed against the person's own spread over the 84 days before the period; the period total against the median of the twelve weeks or six months before it, pro-rated while the period runs. Never a population norm. Weekly goals are reported per calendar row, the monthly step goal for a month. Owned by the Workouts module.",
requestParams: { query: activityCalendarQuerySchema },
responses: {
"200": {
description: "The calendar.",
content: {
"application/json": {
schema: dataEnvelope(
activityCalendarResponseSchema,
"ActivityCalendarEnvelope",
),
},
},
},
...recordRefusal(MODULE_DISABLED_DESCRIPTION),
...stdResponses,
},
},
},
"/api/activity/goals": {
get: {
tags: ["Measurements"],
summary: "Activity goals",
description:
"The person's own activity goals: workouts, active minutes and steps per week, steps per month. `null` = not set; there is no default. Owner only; a delegate reads the goals inside the calendar.",
responses: {
"200": {
description: "The goals.",
content: {
"application/json": {
schema: dataEnvelope(
activityGoalsResponseSchema,
"ActivityGoalsEnvelope",
),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Measurements"],
summary: "Set activity goals",
description:
"Sets the goals the body names and keeps the rest; `null` clears a goal. Owner only: a shared or delegated session cannot change another person's goals.",
requestBody: {
required: true,
content: { "application/json": { schema: activityGoalsPatch } },
},
responses: {
"200": {
description: "The goals after the change.",
content: {
"application/json": {
schema: dataEnvelope(
activityGoalsResponseSchema,
"ActivityGoalsPatchEnvelope",
),
},
},
},
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
};
+77
View File
@@ -23,6 +23,41 @@ import {
stdResponses,
} from "./shared";
import { isoDateTime } from "@/lib/validations/datetime";
import {
labsCsvQuerySchema,
medicationsCsvQuerySchema,
recordImportResultSchema,
} from "@/lib/validations/record-import";
const RECORD_IMPORT_COMMON =
"Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off.";
function recordImportResponses(id: string) {
return {
"200": {
description: "Per-file and per-row outcome.",
content: {
"application/json": {
schema: dataEnvelope(recordImportResultSchema, id),
},
},
},
...stdResponses,
"403": {
description:
"`module.disabled`: the module the import writes to is switched off.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "`import.csv.too_large`: the file is larger than 16 MB.",
content: { "application/json": { schema: errorEnvelope } },
},
"415": {
description: "`import.csv.content_type`: the body is not `text/csv`.",
content: { "application/json": { schema: errorEnvelope } },
},
};
}
const csvImportRowResult = z
.object({
@@ -351,6 +386,48 @@ export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/import/labs-csv": {
post: {
tags: ["Import"],
summary: "Import lab results from CSV",
description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`,
requestParams: { query: labsCsvQuerySchema },
requestBody: {
required: true,
content: {
"text/csv": {
schema: {
type: "string",
example:
"Analyte;Value;Unit;Date;Range\nHbA1c;5,4;%;01.03.2025;4,0-5,6\nUrine protein;negative;;01.03.2025;",
},
},
},
},
responses: recordImportResponses("LabsCsvImportEnvelope"),
},
},
"/api/import/medications-csv": {
post: {
tags: ["Import"],
summary: "Import medications from CSV",
description: `${RECORD_IMPORT_COMMON} Maps name, dose (as written), daily intake times, an as-needed mark and a start and end date; every other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed. Each row is checked against the create route's own schema; a dated window opens a course. Reminders stay off unless \`reminders=1\`. A medication the record holds under the same name and dose is a duplicate. At most 500 data rows.`,
requestParams: { query: medicationsCsvQuerySchema },
requestBody: {
required: true,
content: {
"text/csv": {
schema: {
type: "string",
example:
"name,dose,times,as needed,start,end\nRamipril,5 mg,08:00,,2025-01-01,\nIbuprofen,400 mg,,yes,,",
},
},
},
},
responses: recordImportResponses("MedicationsCsvImportEnvelope"),
},
},
"/api/import": {
post: {
tags: ["Import"],
+4
View File
@@ -27,6 +27,8 @@ import {
recordEpochParameter,
recordScopeParameter,
} from "./account-sharing";
import { activityPaths } from "./activity";
import { provenancePaths } from "./provenance";
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
import { aiUsagePaths } from "./ai-usage";
import { dawarichPaths } from "./dawarich";
@@ -105,6 +107,8 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...syncPaths,
...measurementPaths,
...workoutPaths,
...activityPaths,
...provenancePaths,
...devicePaths,
...medicationPaths,
...profilePaths,
+67
View File
@@ -0,0 +1,67 @@
/**
* OpenAPI route table — source provenance (#1204, #1108, #576).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The schemas
* are the runtime ones from `@/lib/validations/source-provenance`.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import {
dayProvenanceQuerySchema,
dayProvenanceResponseSchema,
sourceComparisonQuerySchema,
sourceComparisonResponseSchema,
} from "@/lib/validations/source-provenance";
import { dataEnvelope, recordRefusal, stdResponses } from "./shared";
export const provenancePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/measurements/provenance": {
get: {
tags: ["Measurements"],
summary: "Which source a metric's day value comes from",
description:
"For one metric and one UTC day: every source with readings, its day value (the total for a cumulative metric, sleep counting asleep stages only, the mean otherwise), its rank on the person's source-priority ladder, and which source the canonical readers use, with the reason. Computed by the same rank SQL the charts, rollups and summaries pick with, so the answer is what they show. A metric without a ladder reports `ranked: false` and no pick: every source counts there. Read-only.",
requestParams: { query: dayProvenanceQuerySchema },
responses: {
"200": {
description: "The day's sources and the pick.",
content: {
"application/json": {
schema: dataEnvelope(
dayProvenanceResponseSchema,
"DayProvenanceEnvelope",
),
},
},
},
...recordRefusal(),
...stdResponses,
},
},
},
"/api/measurements/provenance/compare": {
get: {
tags: ["Measurements"],
summary: "Two sources of one metric side by side",
description:
"Per UTC day over up to 366 days: the day value from source `a` and from source `b`, and which source the ladder picked that day among all of the day's sources. The summary averages only the days both sources cover. Without `a` / `b` the two sources covering the most days are compared. Read-only.",
requestParams: { query: sourceComparisonQuerySchema },
responses: {
"200": {
description: "The comparison.",
content: {
"application/json": {
schema: dataEnvelope(
sourceComparisonResponseSchema,
"SourceComparisonEnvelope",
),
},
},
},
...recordRefusal(),
...stdResponses,
},
},
},
};