Files
pn-new-crm/src/lib/services/onboarding.service.ts
Matt 14ae41d0fa feat(uat-b1): ship Wave A-E of Bucket 1 audit findings
Wave A (Interest+EOI form quick wins):
- Auto-select yacht after inline-create from interest form
- EOI generate dialog: "View EOI" action toast
- Interest form berth picker: formatBerthRange compact label
- Remove "Generate EOI" button from Documents tab (clean removal)
- Interest auto-assign: only sales_agent/sales_manager auto-claim
  ownership on create (explicit role check via user_port_roles join)
- LinkedBerthRowItem dims: drop "D" suffix + "L × W" format
- ExternalEoiUploadDialog: prefillSignatories prop threaded from
  active EOI signers
- EOI signature progress on Overview milestone card footer

Wave B (a11y + i18n sweeps):
- aria-live on supplemental-info error state
- text-[10px] -> text-xs in client-pipeline-summary
- Currency formatter: locale default removed (Intl uses runtime)
- en-US/en-GB hardcoded toLocaleString swept across 13 components

Wave C (Primary berth always in EOI bundle):
- Service guard strengthened on update path
- Migration 0083 backfills historical primary rows

Wave D (Onboarding super_admin discoverability):
- /api/v1/admin/onboarding/status endpoint + shared service
- Topbar OnboardingBanner (super_admin, session-dismissible)
- OnboardingTile dashboard widget (rail group, self-hides at 100%)
- Celebration toast + invalidate of shared status on last tick

Wave E (Branded post-completion email idempotency):
- Verified handleDocumentCompleted already owns the email fan-out
- Added regression test for the polling path + idempotency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:40:37 +02:00

175 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 dont 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,
};
}