From bc87ba641b2caddb66e9d5aa42725154a10bd894 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Thu, 12 Mar 2026 19:59:35 -0700 Subject: [PATCH 1/7] Add onboarding status to Project model and implement related database migrations - Introduced `onboardingStatus` field to the Project model with a default value of "completed". - Created migration scripts to add the new column and validate its values against predefined statuses. - Updated relevant queries and components to handle the new onboarding status in project creation and updates. - Enhanced tests to ensure proper functionality of the onboarding status feature. --- .../migration.sql | 16 + .../tests/default-and-constraint.ts | 50 + .../migration.sql | 2 + .../tests/constraint-is-validated.ts | 13 + apps/backend/prisma/schema.prisma | 1 + apps/backend/src/lib/projects.tsx | 17 + .../new-project/page-client.tsx | 1835 +++++++++++++++-- .../projects/page-client.tsx | 95 +- .../dashboard/src/components/project-card.tsx | 36 +- .../api/v1/internal/projects.test.ts | 12 + claude/CLAUDE-KNOWLEDGE.md | 12 + .../src/interface/crud/projects.ts | 2 + packages/stack-shared/src/schema-fields.ts | 16 + .../apps/implementations/admin-app-impl.ts | 1 + .../src/lib/stack-app/projects/index.ts | 4 + 15 files changed, 1952 insertions(+), 160 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql create mode 100644 apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/tests/default-and-constraint.ts create mode 100644 apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/migration.sql create mode 100644 apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/tests/constraint-is-validated.ts diff --git a/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql b/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql new file mode 100644 index 0000000000..e238be468d --- /dev/null +++ b/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql @@ -0,0 +1,16 @@ +ALTER TABLE "Project" +ADD COLUMN "onboardingStatus" TEXT NOT NULL DEFAULT 'completed'; + +ALTER TABLE "Project" +ADD CONSTRAINT "Project_onboardingStatus_valid" +CHECK ( + "onboardingStatus" IN ( + 'config_choice', + 'apps_selection', + 'auth_setup', + 'domain_setup', + 'email_theme_setup', + 'payments_setup', + 'completed' + ) +) NOT VALID; diff --git a/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/tests/default-and-constraint.ts b/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/tests/default-and-constraint.ts new file mode 100644 index 0000000000..047748b946 --- /dev/null +++ b/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/tests/default-and-constraint.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Onboarding Test', '', false) + `; + return { projectId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const rows = await sql` + SELECT "onboardingStatus" + FROM "Project" + WHERE "id" = ${ctx.projectId} + `; + expect(rows).toHaveLength(1); + expect(rows[0].onboardingStatus).toBe("completed"); + + const validProjectId = `test-${randomUUID()}`; + await sql` + INSERT INTO "Project" ( + "id", + "createdAt", + "updatedAt", + "displayName", + "description", + "isProductionMode", + "onboardingStatus" + ) + VALUES (${validProjectId}, NOW(), NOW(), 'Valid Status Project', '', false, 'auth_setup') + `; + + const invalidProjectId = `test-${randomUUID()}`; + await expect(sql` + INSERT INTO "Project" ( + "id", + "createdAt", + "updatedAt", + "displayName", + "description", + "isProductionMode", + "onboardingStatus" + ) + VALUES (${invalidProjectId}, NOW(), NOW(), 'Invalid Status Project', '', false, 'invalid_status') + `).rejects.toThrow(/Project_onboardingStatus_valid/); +}; diff --git a/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/migration.sql b/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/migration.sql new file mode 100644 index 0000000000..4454dcd782 --- /dev/null +++ b/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Project" +VALIDATE CONSTRAINT "Project_onboardingStatus_valid"; diff --git a/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/tests/constraint-is-validated.ts b/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/tests/constraint-is-validated.ts new file mode 100644 index 0000000000..90c7ff5d45 --- /dev/null +++ b/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/tests/constraint-is-validated.ts @@ -0,0 +1,13 @@ +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const postMigration = async (sql: Sql) => { + const rows = await sql` + SELECT "convalidated" + FROM "pg_constraint" + WHERE "conname" = 'Project_onboardingStatus_valid' + `; + + expect(rows).toHaveLength(1); + expect(rows[0].convalidated).toBe(true); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 17760a2430..0d408ff0ba 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -26,6 +26,7 @@ model Project { description String @default("") isProductionMode Boolean ownerTeamId String? @db.Uuid + onboardingStatus String @default("completed") logoUrl String? logoFullUrl String? diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 78b6db54a1..52a0bcb5c7 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -66,6 +66,7 @@ export function getProjectQuery(projectId: string): RawQuery = [ + { id: "credential", label: "Email & password" }, + { id: "magicLink", label: "Magic link / OTP" }, + { id: "passkey", label: "Passkey" }, + { id: "google", label: "Google" }, + { id: "github", label: "GitHub" }, + { id: "microsoft", label: "Microsoft" }, +]; + +const REQUIRED_APP_IDS: AppId[] = ["authentication", "emails"]; +const PRIMARY_APP_IDS: AppId[] = ["authentication", "emails", "payments", "analytics"]; +const ALL_APP_IDS = Object.keys(ALL_APPS) as AppId[]; + +type StackAppInternals = { + sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise, + refreshOwnedProjects: () => Promise, +}; + +type TimelineStep = { + id: ProjectOnboardingStatus, + label: string, +}; + +const PAYMENT_COUNTRY_OPTIONS = [ + { value: "US", label: "United States" }, + { value: "OTHER", label: "Other" }, +] as const; + +function isStackAppInternals(value: unknown): value is StackAppInternals { + return ( + value != null + && typeof value === "object" + && "sendRequest" in value + && typeof value.sendRequest === "function" + && "refreshOwnedProjects" in value + && typeof value.refreshOwnedProjects === "function" + ); +} + +function getStackAppInternals(appValue: unknown): StackAppInternals { + if (appValue == null || typeof appValue !== "object") { + throw new Error("The Stack app instance is unavailable."); + } + + const internals = Reflect.get(appValue, stackAppInternalsSymbol); + if (!isStackAppInternals(internals)) { + throw new Error("The Stack client app cannot send internal requests."); + } + + return internals; +} + +function isProjectOnboardingStatus(value: unknown): value is ProjectOnboardingStatus { + return typeof value === "string" && PROJECT_ONBOARDING_STATUSES.some((status) => status === value); +} + +function orderedAppIds() { + const primarySet = new Set(PRIMARY_APP_IDS); + const secondary = ALL_APP_IDS.filter((appId) => !primarySet.has(appId)).sort((a, b) => { + return stringCompare(ALL_APPS[a].displayName, ALL_APPS[b].displayName); + }); + return [...PRIMARY_APP_IDS, ...secondary]; +} + +function normalizeTrustedDomain(input: string): string { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return ""; + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error("Trusted domain must be a valid URL."); + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("Trusted domain must start with http:// or https://."); + } + + return parsed.toString().replace(/\/$/, ""); +} + +function buildTimeline(includePayments: boolean): TimelineStep[] { + const timeline: TimelineStep[] = [ + { id: "config_choice", label: "Config" }, + { id: "apps_selection", label: "Apps" }, + { id: "auth_setup", label: "Auth" }, + { id: "domain_setup", label: "Domains" }, + { id: "email_theme_setup", label: "Email Theme" }, + ]; + + if (includePayments) { + timeline.push({ id: "payments_setup", label: "Payments" }); + } + + timeline.push({ id: "completed", label: "Finish" }); + return timeline; +} + +function deriveInitialSignInMethods(project: AdminOwnedProject): Set { + const config = project.config; + const methods = new Set([ + "credential", + "magicLink", + "google", + "github", + ]); + + if (config.credentialEnabled) { + methods.add("credential"); + } + if (config.magicLinkEnabled) { + methods.add("magicLink"); + } + if (config.passkeyEnabled) { + methods.add("passkey"); + } + + for (const provider of config.oauthProviders) { + if (provider.id === "google" || provider.id === "github" || provider.id === "microsoft") { + methods.add(provider.id); + } + } + + return methods; +} + +function deriveInitialApps(config: ReturnType): Set { + const enabledApps = new Set(); + + for (const appId of ALL_APP_IDS) { + if (config.apps.installed[appId]?.enabled) { + enabledApps.add(appId); + } + } + + if (enabledApps.size === 0) { + for (const primaryAppId of PRIMARY_APP_IDS) { + enabledApps.add(primaryAppId); + } + } + + for (const requiredAppId of REQUIRED_APP_IDS) { + enabledApps.add(requiredAppId); + } + + return enabledApps; +} + +function getStepIndex(steps: TimelineStep[], stepId: ProjectOnboardingStatus) { + return steps.findIndex((step) => step.id === stepId); +} + +function OnboardingTimeline(props: { + steps: TimelineStep[], + currentStep: ProjectOnboardingStatus, + onStepClick?: (step: ProjectOnboardingStatus) => void, + disabled?: boolean, +}) { + const currentIndex = props.steps.findIndex((step) => step.id === props.currentStep); + + return ( +
+
+ {props.steps.map((step, index) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + const isClickable = isComplete && !props.disabled && props.onStepClick != null; + const circleClassName = isComplete + ? "bg-green-500 text-white" + : isCurrent + ? "bg-blue-600 text-white" + : "bg-muted text-muted-foreground"; + + return ( +
+ {isClickable ? ( + + ) : ( +
+
+ {isComplete ? : index + 1} +
+ {step.label} +
+ )} + {index < props.steps.length - 1 &&
} +
+ ); + })} +
+
+ ); +} + +function appStageBadgeColor(stage: (typeof ALL_APPS)[AppId]["stage"]) { + if (stage === "alpha") { + return "orange"; + } + if (stage === "beta") { + return "blue"; + } + return null; +} + +function appStageLabel(stage: (typeof ALL_APPS)[AppId]["stage"]) { + if (stage === "alpha") { + return "Alpha"; + } + if (stage === "beta") { + return "Beta"; + } + return null; +} + +function OnboardingAppCard(props: { + appId: AppId, + selected: boolean, + required: boolean, + primary: boolean, + disabled?: boolean, + onToggle: () => void, +}) { + const app = ALL_APPS[props.appId]; + const stageBadgeColor = appStageBadgeColor(app.stage); + const stageLabel = appStageLabel(app.stage); + + return ( + + + + + +
+
+ {app.displayName} + {stageBadgeColor && ( + + )} +
+ + {app.subtitle} + +
+
+
+ ); +} + +function OnboardingEmailThemePreview(props: { + adminApp: AdminOwnedProject["app"], + themeId: string, +}) { + const previewHtml = props.adminApp.useEmailPreview({ + themeId: props.themeId, + templateTsxSource: previewTemplateSource, + }); + + const inertPreviewHtml = previewHtml ? `${previewHtml} + + ` : previewHtml; + + return ( +