mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user