diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6868782b2..b55ba0e06 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6749,6 +6749,30 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/onboarding/tour: + post: + tags: + - Onboarding + summary: Update module-tour completion + resume point + description: Persists the module-tour state. The client posts a fire-and-forget `progress` checkpoint on each step so a + reload resumes at the right module, and a terminal `completed:true` with `outcome` when the tour ends. + `completed:false` is a replay reset that also clears the resume point. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TourUpdateRequest" + responses: + "200": + description: Tour state updated. + content: + application/json: + schema: + $ref: "#/components/schemas/TourUpdateEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -8079,6 +8103,55 @@ components: type: string required: - ids + TourUpdateRequest: + type: object + properties: + completed: + description: Flip the coarse completion flag. `false` is a replay reset and clears the stored progress point. + type: boolean + outcome: + description: Informational — distinguishes reaching the end from a skip. + type: string + enum: + - completed + - skipped + progress: + description: Mid-tour resume checkpoint. May arrive alone or with `completed`. + $ref: "#/components/schemas/TourProgress" + description: Update the module-tour state. Provide `completed` and/or `progress`. + TourProgress: + type: object + properties: + lastStopId: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + completedStopIds: + default: [] + maxItems: 64 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + status: + type: string + enum: + - in_progress + - completed + - skipped + updatedAt: + 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))$ + required: + - lastStopId + - status + - updatedAt + description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal + state. MedicationTreatmentClass: type: string enum: @@ -19363,6 +19436,37 @@ components: description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, and a per-type breakdown. Retrospective only." + TourUpdateEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/TourUpdateResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TourUpdateResponse: + type: object + properties: + onboardingTourCompleted: + type: boolean + progress: + anyOf: + - $ref: "#/components/schemas/TourProgressOutput" + - type: "null" + required: + - onboardingTourCompleted + - progress + additionalProperties: false + description: The persisted completion flag and resume point after the write. MedicationScheduleOutput: type: object properties: @@ -19634,6 +19738,41 @@ components: compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version." + TourProgressOutput: + type: object + properties: + lastStopId: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + completedStopIds: + default: [] + maxItems: 64 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + status: + type: string + enum: + - in_progress + - completed + - skipped + updatedAt: + 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))$ + required: + - lastStopId + - completedStopIds + - status + - updatedAt + additionalProperties: false + description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal + state. Medication: type: object properties: diff --git a/prisma/migrations/0173_v1186_tour_progress/migration.sql b/prisma/migrations/0173_v1186_tour_progress/migration.sql new file mode 100644 index 000000000..498a859f5 --- /dev/null +++ b/prisma/migrations/0173_v1186_tour_progress/migration.sql @@ -0,0 +1,22 @@ +-- v1.18.6 — resumable module-tour progress. +-- +-- A single nullable JSONB column on `users` carrying the fine-grained +-- resume point for the module-by-module guided tour. Follows the +-- established per-user JSON-column convention (`dashboard_widgets_json`, +-- `insights_layout_json`, `coach_prefs_json`). +-- +-- Shape (validated by `src/lib/onboarding/tour-progress.ts`): +-- { "lastStopId": string | null, +-- "completedStopIds": string[], +-- "status": "in_progress" | "completed" | "skipped", +-- "updatedAt": string (ISO 8601) } +-- +-- NULL = the user has not started the module tour. The coarse +-- `onboarding_tour_completed` boolean stays the launcher's auto-launch +-- gate; this column is purely the resume point and is cleared back to +-- NULL on a Settings replay so the tour genuinely restarts. +-- +-- Additive + nullable: every existing account keeps NULL and the +-- application resolver treats NULL as "not started". + +ALTER TABLE "users" ADD COLUMN "onboarding_tour_progress_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a69270678..948d0db58 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -256,6 +256,22 @@ model User { // Settings → Account. onboardingTourCompleted Boolean @default(false) @map("onboarding_tour_completed") + // v1.18.6 — resumable module-tour progress. The coarse boolean above + // stays as the "did the user ever finish/dismiss" flag the launcher + // gates on; this column carries the fine-grained resume point so a + // mid-tour reload reopens at the right module. Follows the per-user + // JSON-column convention (`dashboardWidgetsJson` / `insightsLayoutJson` + // / `coachPrefsJson`). Shape (validated by + // `src/lib/onboarding/tour-progress.ts`): + // { lastStopId: string | null, + // completedStopIds: string[], + // status: "in_progress" | "completed" | "skipped", + // updatedAt: string (ISO) } + // Null = the user has not started the module tour (fresh account or a + // legacy account that only carries the boolean flag). Cleared back to + // null on a Settings replay so the tour genuinely restarts. + onboardingTourProgressJson Json? @map("onboarding_tour_progress_json") + // HealthKit (iOS) integration config — list of per-metric direction toggles. // Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt } healthKitConfigJson Json? @map("healthkit_config_json") diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index f7fec1a52..d23f63b8e 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -10,6 +10,7 @@ import { resolveModuleMap, getOperatorModuleAvailability, } from "@/lib/modules/gate"; +import { parseTourProgress } from "@/lib/onboarding/tour-progress"; export const dynamic = "force-dynamic"; @@ -70,6 +71,11 @@ export const GET = apiHandler(async () => { timezone: user.timezone, onboardingCompletedAt: user.onboardingCompletedAt, onboardingTourCompleted: user.onboardingTourCompleted, + // v1.18.6 — resumable module-tour progress. Null when the user has + // not started the tour; otherwise the resume point the launcher + // seeds its index from. Fail-soft parse: a corrupt blob degrades to + // null ("start from the top") rather than 500-ing the /me read. + onboardingTourProgress: parseTourProgress(user.onboardingTourProgressJson), // v1.5.5 — self-hosted avatar. Replaces the Gravatar leak; the // URL is relative so PWA + native clients render identically // and the `?v={updatedAtMs}` suffix busts the browser cache on diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts index 9e9b72a4a..8d88d408a 100644 --- a/src/app/api/onboarding/tour/route.ts +++ b/src/app/api/onboarding/tour/route.ts @@ -5,30 +5,45 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; +import { Prisma } from "@/generated/prisma/client"; /** * v1.4.15 Phase B5 — onboarding tour completion endpoint. + * v1.18.6 — extended with the resumable module-tour progress point. * - * Two operations on a single resource: + * Operations on a single resource: * * POST { completed: true, outcome: "completed" | "skipped" } * → flips `users.onboarding_tour_completed` to true. Auto-fires - * when the user reaches the end of the spotlight tour OR - * explicitly dismisses it. + * when the user reaches the end of the module tour OR explicitly + * dismisses it. * * POST { completed: false } * → resets the flag so the user can replay the tour from - * Settings → Account → "Restart onboarding tour". + * Settings → Advanced → "Modul-Tour neu starten". Also CLEARS + * `onboarding_tour_progress_json` back to null so the replay + * genuinely starts from the first module. * - * The `outcome` field is informational — it lands in the structured - * Wide Event annotation so analytics can tell completion from - * dismiss without writing two columns. Same DB write either way. + * POST { progress: { lastStopId, completedStopIds, status, updatedAt } } + * → fire-and-forget mid-tour checkpoint so a reload resumes at the + * right module. May arrive WITH `completed` (the terminal + * checkpoint sends both) or alone (every `nextStep`). + * + * `outcome` is informational — it lands in the structured Wide Event + * annotation so analytics can tell completion from dismiss without a + * second column. Same DB write either way. */ -const tourBodySchema = z.object({ - completed: z.boolean(), - outcome: z.enum(["completed", "skipped"]).optional(), -}); +const tourBodySchema = z + .object({ + completed: z.boolean().optional(), + outcome: z.enum(["completed", "skipped"]).optional(), + progress: tourProgressSchema.optional(), + }) + .refine((b) => b.completed !== undefined || b.progress !== undefined, { + message: "Provide `completed` and/or `progress`.", + }); export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -43,18 +58,36 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid input", 422); } - await prisma.user.update({ - where: { id: user.id }, - data: { onboardingTourCompleted: parsed.data.completed }, - }); + // Build the update field-by-field (no mass assignment). `completed` + // drives the coarse boolean; a replay (`completed:false`) wipes the + // resume point; a progress checkpoint writes the JSON column. + const data: Prisma.UserUpdateInput = {}; + if (parsed.data.completed !== undefined) { + data.onboardingTourCompleted = parsed.data.completed; + if (parsed.data.completed === false) { + // Replay — start the resume point over. + data.onboardingTourProgressJson = Prisma.JsonNull; + } + } + if (parsed.data.progress !== undefined) { + data.onboardingTourProgressJson = parsed.data.progress; + } + + await prisma.user.update({ where: { id: user.id }, data }); annotate({ action: { name: "onboarding.tour.update" }, meta: { - completed: parsed.data.completed, + completed: parsed.data.completed ?? null, outcome: parsed.data.outcome ?? null, + progressStatus: parsed.data.progress?.status ?? null, + lastStopId: parsed.data.progress?.lastStopId ?? null, }, }); - return apiSuccess({ onboardingTourCompleted: parsed.data.completed }); + return apiSuccess({ + onboardingTourCompleted: + parsed.data.completed ?? user.onboardingTourCompleted, + progress: parsed.data.progress ?? null, + }); }); diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index 495fa9a59..aaf77d38b 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -63,6 +63,7 @@ function buildUser(disableCoach: boolean): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index af1183e3b..32d23c180 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -69,6 +69,7 @@ function buildUser(modules: AuthUser["modules"]): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index 848d95017..2bb55ac6b 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -41,6 +41,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index 13430adf1..c5e0a3b13 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -44,6 +44,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference, diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index ba079e0c7..4fcf04a7d 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -10,8 +10,15 @@ import { useTranslations } from "@/lib/i18n/context"; import type { TimeFormatPreference } from "@/lib/format-locale"; import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; import type { ModuleKey } from "@/lib/modules/registry"; +import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; +/** + * v1.18.6 — resume point for the module tour as the client sees it on + * `/api/auth/me`. Mirrors the server-side `TourProgress` shape. + */ +export type AuthTourProgress = TourProgress; + export interface AuthUser { id: string; username: string; @@ -29,6 +36,13 @@ export interface AuthUser { * `` component for the gating logic. */ onboardingTourCompleted: boolean; + /** + * v1.18.6 — resumable module-tour progress, or null when the user + * has not started the tour. The `` seeds its resume + * index from `lastStopId`. Distinct from the coarse + * `onboardingTourCompleted` boolean, which stays the auto-launch gate. + */ + onboardingTourProgress: AuthTourProgress | null; /** * v1.5.5 — relative URL of the user's self-hosted avatar, served * from `/api/user/avatar/{id}?v={updatedAtMs}`. Replaces the @@ -137,6 +151,9 @@ async function fetchMe(): Promise { return { ...(data as AuthUser), disableCoach: data.disableCoach ?? false, + // v1.18.6 — coerce against a stale /me payload (older server image + // without the field) to null so the tour starts from the top. + onboardingTourProgress: data.onboardingTourProgress ?? null, // v1.7.0 — coerce against a stale /me payload (older server image // without the field) so the display defaults to metric. unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric", diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 594a040ad..9120db140 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -91,6 +91,7 @@ function buildUser(disableCoach: boolean): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", diff --git a/src/lib/onboarding/__tests__/tour-progress.test.ts b/src/lib/onboarding/__tests__/tour-progress.test.ts new file mode 100644 index 000000000..52a108e8d --- /dev/null +++ b/src/lib/onboarding/__tests__/tour-progress.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; + +import { + parseTourProgress, + tourProgressSchema, + type TourProgress, +} from "@/lib/onboarding/tour-progress"; + +const valid: TourProgress = { + lastStopId: "labs", + completedStopIds: ["dashboardOverview", "quickAdd"], + status: "in_progress", + updatedAt: "2026-06-18T10:00:00.000Z", +}; + +describe("tour-progress", () => { + it("parses a well-formed progress object", () => { + expect(parseTourProgress(valid)).toEqual(valid); + }); + + it("treats null / undefined as not-started", () => { + expect(parseTourProgress(null)).toBeNull(); + expect(parseTourProgress(undefined)).toBeNull(); + }); + + it("degrades a corrupt blob to null rather than throwing", () => { + expect(parseTourProgress({ status: "bogus" })).toBeNull(); + expect(parseTourProgress({ lastStopId: 42 })).toBeNull(); + expect(parseTourProgress("not-an-object")).toBeNull(); + }); + + it("defaults completedStopIds to an empty array", () => { + const parsed = tourProgressSchema.parse({ + lastStopId: null, + status: "skipped", + updatedAt: "2026-06-18T10:00:00.000Z", + }); + expect(parsed.completedStopIds).toEqual([]); + }); + + it("accepts a null lastStopId (centred/wrap stop or fresh start)", () => { + expect(parseTourProgress({ ...valid, lastStopId: null })).not.toBeNull(); + }); + + it("rejects an over-long completedStopIds array", () => { + const tooMany = Array.from({ length: 65 }, (_, i) => `s${i}`); + expect( + parseTourProgress({ ...valid, completedStopIds: tooMany }), + ).toBeNull(); + }); +}); diff --git a/src/lib/onboarding/tour-progress.ts b/src/lib/onboarding/tour-progress.ts new file mode 100644 index 000000000..b186ce7d3 --- /dev/null +++ b/src/lib/onboarding/tour-progress.ts @@ -0,0 +1,53 @@ +/** + * v1.18.6 — resumable module-tour progress. + * + * Pure data + Zod schema for the `users.onboarding_tour_progress_json` + * column. Kept DOM-free + free of Prisma imports so vitest (node env) + * and the OpenAPI registry can both consume it. + * + * The coarse `onboardingTourCompleted` boolean stays the launcher's + * auto-launch gate; this object is the fine-grained resume point so a + * mid-tour reload reopens at the right module. `null` = the user has + * not started the module tour. + */ + +import { z } from "zod/v4"; + +/** Terminal / running status of the module tour. */ +export const TOUR_PROGRESS_STATUSES = [ + "in_progress", + "completed", + "skipped", +] as const; + +export type TourProgressStatus = (typeof TOUR_PROGRESS_STATUSES)[number]; + +/** + * The persisted progress shape. `lastStopId` seeds the resume index; + * `completedStopIds` is informational (analytics + future per-module + * "already seen" hints). Stop ids are bounded strings — the client + * sends a known stop id from `buildTourStops()`, but the server does + * not couple to that closed set (a future stop must not 422 an older + * client and vice-versa), so the field is a length-capped string array + * rather than a hard enum. + */ +export const tourProgressSchema = z.object({ + lastStopId: z.string().min(1).max(64).nullable(), + completedStopIds: z.array(z.string().min(1).max(64)).max(64).default([]), + status: z.enum(TOUR_PROGRESS_STATUSES), + updatedAt: z.iso.datetime(), +}); + +export type TourProgress = z.infer; + +/** + * Parse a value read from the JSON column. Returns `null` for a null / + * absent column AND for any malformed payload — the resume point is a + * non-critical convenience, so a corrupt blob degrades to "start from + * the top" rather than throwing into the auth/me read path. + */ +export function parseTourProgress(value: unknown): TourProgress | null { + if (value == null) return null; + const parsed = tourProgressSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index dbab3937c..0ac6f7361 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -42,6 +42,7 @@ import { measurementReminderPaths } from "./measurement-reminders"; import { medicationPaths, medicationResource } from "./medications"; import { metaPaths } from "./meta"; import { moodPaths } from "./mood"; +import { onboardingPaths } from "./onboarding"; import { profilePaths } from "./profile"; import { settingsPaths } from "./settings"; import { syncPaths } from "./sync"; @@ -72,6 +73,7 @@ export const openApiPaths: NonNullable = { ...labsPaths, ...biomarkerPaths, ...illnessPaths, + ...onboardingPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts new file mode 100644 index 000000000..efdc1b4cf --- /dev/null +++ b/src/lib/openapi/routes/onboarding.ts @@ -0,0 +1,81 @@ +/** + * OpenAPI route table — onboarding module tour. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * The request schema comes from `src/lib/onboarding/tour-progress.ts` + * so the wire contract stays single-source with the runtime parser. + * + * v1.18.6 — the resumable module-tour contract the iOS client mirrors: + * a fire-and-forget progress checkpoint plus the coarse completion + * flip. The resume point also rides `GET /api/auth/me` as + * `onboardingTourProgress`. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; +import { dataEnvelope, stdResponses } from "./shared"; + +const tourProgressResource = tourProgressSchema.meta({ + id: "TourProgress", + description: + "Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal state.", +}); + +const tourUpdateRequest = z + .object({ + completed: z + .boolean() + .optional() + .describe( + "Flip the coarse completion flag. `false` is a replay reset and clears the stored progress point.", + ), + outcome: z + .enum(["completed", "skipped"]) + .optional() + .describe("Informational — distinguishes reaching the end from a skip."), + progress: tourProgressResource + .optional() + .describe("Mid-tour resume checkpoint. May arrive alone or with `completed`."), + }) + .meta({ + id: "TourUpdateRequest", + description: + "Update the module-tour state. Provide `completed` and/or `progress`.", + }); + +const tourUpdateResponse = z + .object({ + onboardingTourCompleted: z.boolean(), + progress: tourProgressResource.nullable(), + }) + .meta({ + id: "TourUpdateResponse", + description: "The persisted completion flag and resume point after the write.", + }); + +export const onboardingPaths: NonNullable = { + "/api/onboarding/tour": { + post: { + tags: ["Onboarding"], + summary: "Update module-tour completion + resume point", + description: + "Persists the module-tour state. The client posts a fire-and-forget `progress` checkpoint on each step so a reload resumes at the right module, and a terminal `completed:true` with `outcome` when the tour ends. `completed:false` is a replay reset that also clears the resume point.", + requestBody: { + required: true, + content: { "application/json": { schema: tourUpdateRequest } }, + }, + responses: { + "200": { + description: "Tour state updated.", + content: { + "application/json": { + schema: dataEnvelope(tourUpdateResponse, "TourUpdateEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, +};