/** * Server-side onboarding status resolver. Shared by the admin checklist * page, the topbar discoverability banner, and the dashboard onboarding * tile so all three surfaces always agree on what's "done." * * Steps + auto-check rules live here (single source of truth); the UI * surfaces consume the resolved status via the `/api/v1/admin/onboarding/status` * endpoint. */ import { and, eq, isNull, or } from 'drizzle-orm'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema/system'; import { resolveForAdminAPI } from '@/lib/settings/resolver'; export interface OnboardingStep { id: string; href: string; label: string; description: string; autoCheckSettingKey?: string; autoCheckSettingKeysAll?: readonly string[]; /** When set, the step is marked done when the named list endpoint returns * at least one row. The endpoint URL is read by the client only; * server-side resolver treats these as manual-only. */ autoCheckListEndpoint?: string; } export const ONBOARDING_STEPS: readonly OnboardingStep[] = [ { id: 'branding', href: 'branding', label: 'Set port name, logo, primary colour', description: 'Branding flows into the navbar, emails, EOI PDFs, and the public auth shell.', autoCheckSettingKey: 'branding_logo_url', }, { id: 'email', href: 'email', label: 'Configure outgoing email', description: 'From-address, signature, footer, plus per-port SMTP overrides if you don’t use the global account.', autoCheckSettingKey: 'smtp_host_override', }, { id: 'documenso', href: 'documenso', label: 'Connect Documenso for EOIs', description: 'API credentials, the EOI template id, plus the developer + approver identity that signs every EOI.', autoCheckSettingKeysAll: [ 'documenso_api_url_override', 'documenso_developer_email', 'documenso_approver_email', 'documenso_eoi_template_id', ], }, { id: 'settings', href: 'settings', label: 'Tune business rules + recommender weights', description: 'Pipeline weights, net-10 discount, berth recommender knobs (heat weights, fall-through policy).', autoCheckSettingKey: 'heat_weight_recency', }, { id: 'roles', href: 'roles', label: 'Create roles & assign users', description: 'Per-port roles inherit from system roles; override permissions here.', autoCheckListEndpoint: '/api/v1/admin/roles', }, { id: 'users', href: 'users', label: 'Invite the rest of the team', description: 'Invite users, assign roles, optionally grant residential access. Track pending vs accepted.', autoCheckListEndpoint: '/api/v1/admin/users', }, { id: 'tags', href: 'tags', label: 'Define starter tags', description: 'Color-coded labels used across clients, yachts, companies, and interests.', autoCheckListEndpoint: '/api/v1/tags/options', }, { id: 'storage', href: 'storage', label: 'Configure storage backend', description: 'Verify S3/filesystem and run a test connection before going live so PDFs and avatars persist correctly.', autoCheckSettingKey: 'storage_backend', }, { id: 'forms', href: 'forms', label: 'Wire the website intake forms', description: 'Inquiry forms on the marketing site dual-write into the CRM via /api/public/website-inquiries. Manually mark complete when verified.', }, ]; export interface OnboardingStatus { steps: Array; completed: number; total: number; percent: number; isComplete: boolean; /** The next undone step the admin should tackle. Null when complete. */ nextStep: (OnboardingStep & { done: false }) | null; } /** * Resolves onboarding status for the given port. Auto-checks read the full * setting chain (port → global → env → default) via `resolveSettings`; * list-endpoint checks are treated as not-auto-resolvable server-side and * fall back to the manual-checkbox state in `onboarding_manual_status`. */ export async function resolveOnboardingStatus(portId: string): Promise { const keys = new Set(); for (const s of ONBOARDING_STEPS) { if (s.autoCheckSettingKey) keys.add(s.autoCheckSettingKey); if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k); } const resolved = keys.size > 0 ? await resolveForAdminAPI(Array.from(keys), portId) : new Map(); // Manual-checkbox state lives in a JSON blob row outside the registry. // Same lookup pattern as the admin page: port-scoped row first, fall // back to a global one when the port hasn't written its own. const manualRow = await db .select({ value: systemSettings.value }) .from(systemSettings) .where( and( eq(systemSettings.key, 'onboarding_manual_status'), or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)), ), ) .limit(1); const manual = (manualRow[0]?.value ?? {}) as Record; let firstUndone: (OnboardingStep & { done: false }) | null = null; const steps = ONBOARDING_STEPS.map((step) => { let autoDone = false; if (step.autoCheckSettingKey) { autoDone = Boolean(resolved.get(step.autoCheckSettingKey)?.isSet); } else if (step.autoCheckSettingKeysAll) { autoDone = step.autoCheckSettingKeysAll.every((k) => Boolean(resolved.get(k)?.isSet)); } const manualDone = Boolean(manual[step.id]); const done = autoDone || manualDone; if (!done && !firstUndone) firstUndone = { ...step, done: false }; return { ...step, done, auto: autoDone }; }); const completed = steps.filter((s) => s.done).length; const total = steps.length; const percent = total > 0 ? Math.round((completed / total) * 100) : 0; return { steps, completed, total, percent, isComplete: completed === total, nextStep: firstUndone, }; }