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>
This commit is contained in:
174
src/lib/services/onboarding.service.ts
Normal file
174
src/lib/services/onboarding.service.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user