feat: post-signup onboarding wizard (dietary/allergen prefs, notifications, skippable) (v0.77.0)

New accounts land on a 3-step wizard after signup — welcome, dietary preferences & allergens, notification opt-in — skippable at every step, shown once via a users.onboardingCompletedAt gate in the (app) layout. Existing accounts are backfilled as already-onboarded.

Also gives the allergen-preferences step a real write path: user_allergens previously only had a GDPR-export read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 11:46:07 +02:00
parent 003e8abe22
commit 4aa47ca61d
17 changed files with 6628 additions and 6 deletions
@@ -0,0 +1,5 @@
ALTER TABLE "users" ADD COLUMN "onboarding_completed_at" timestamp;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "dietary_tags" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
-- Backfill existing accounts as already onboarded — the wizard is only for
-- users who sign up after this migration, not everyone who predates it.
UPDATE "users" SET "onboarding_completed_at" = "created_at" WHERE "onboarding_completed_at" IS NULL;
File diff suppressed because it is too large Load Diff
@@ -449,6 +449,13 @@
"when": 1784885406472,
"tag": "0063_sharp_stone_men",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1784885885146,
"tag": "0064_thick_drax",
"breakpoints": true
}
]
}
+19
View File
@@ -4,11 +4,25 @@ import {
timestamp,
boolean,
integer,
jsonb,
pgEnum,
primaryKey,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
// Mirrors recipes.ts's DietaryTags shape — duplicated rather than imported to
// avoid a users.ts <-> recipes.ts circular import (recipes.ts already
// references users.ts for the authorId FK).
export type UserDietaryTags = {
vegan?: boolean;
vegetarian?: boolean;
glutenFree?: boolean;
dairyFree?: boolean;
nutFree?: boolean;
halal?: boolean;
kosher?: boolean;
};
export const userRoleEnum = pgEnum("user_role", ["user", "moderator", "admin"]);
export const tierEnum = pgEnum("tier", ["free", "pro", "family"]);
export const unitPrefEnum = pgEnum("unit_pref", ["metric", "imperial"]);
@@ -55,6 +69,11 @@ export const users = pgTable("users", {
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
locale: text("locale").notNull().default("en"),
twoFactorEnabled: boolean("two_factor_enabled").notNull().default(false),
// Null = hasn't been through (or finished/skipped) the post-signup
// onboarding wizard yet. Set once, either on completion or on skip — both
// count as "done," so the wizard never shows twice.
onboardingCompletedAt: timestamp("onboarding_completed_at"),
dietaryTags: jsonb("dietary_tags").$type<UserDietaryTags>().default({}),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});