mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(onboarding): add resumable module-tour progress field + API
Add nullable onboarding_tour_progress_json to users (migration 0173), a Zod-validated progress shape, and extend POST /api/onboarding/tour to accept a mid-tour resume checkpoint and clear it on replay. Surface the resume point on GET /api/auth/me and register the contract in OpenAPI so the native client mirrors it.
This commit is contained in:
1 parent
b474ea1489
commit
59e7b33285
15 files changed
+442
-17
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
onboardingTourProgress: null,
|
||||
avatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
unitPreference: "metric",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -41,6 +41,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser {
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
onboardingTourProgress: null,
|
||||
avatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
unitPreference: "metric",
|
||||
|
||||
@@ -44,6 +44,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
onboardingTourProgress: null,
|
||||
avatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
unitPreference,
|
||||
|
||||
@@ -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 {
|
||||
* `<TourLauncher>` 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 `<TourLauncher>` 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<AuthUser> {
|
||||
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",
|
||||
|
||||
@@ -91,6 +91,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
onboardingTourProgress: null,
|
||||
avatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
unitPreference: "metric",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof tourProgressSchema>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
...labsPaths,
|
||||
...biomarkerPaths,
|
||||
...illnessPaths,
|
||||
...onboardingPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user