175 lines
5.9 KiB
TypeScript
175 lines
5.9 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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<OnboardingStep & { done: boolean; auto: boolean }>;
|
|||
|
|
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<OnboardingStatus> {
|
|||
|
|
const keys = new Set<string>();
|
|||
|
|
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<string, { isSet: boolean }>();
|
|||
|
|
|
|||
|
|
// 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<string, boolean>;
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
};
|
|||
|
|
}
|