fix(audit-wave-9): onboarding + first-run UX fixes (onboarding-auditor)
Address the CRITICAL and high-leverage HIGH items from the onboarding-auditor report: **C1 — checklist auto-checks were reading the wrong setting keys** A port that had actually been configured still showed three steps as incomplete, permanently capping the checklist at < 70 %. - email step: `sales_email_smtp_host` → `smtp_host_override` (the key the email admin page actually persists). - documenso step: `documenso_api_url` → compound gate `documenso_api_url_override` + `documenso_developer_email` + `documenso_approver_email` + `documenso_eoi_template_id`. All four are required for `buildDocumensoPayload` not to error out; checking only the URL falsely greenlit the step until a rep tried to send an EOI and Documenso 404'd. - settings step: `recommender_top_n_default` → `heat_weight_recency`. The defaults are layered (port > global > built-in), so a port using the built-ins never writes the `top_n_default` row — old key was an unreachable green. heat_weight_recency genuinely means "admin tuned the recommender". **C2 — forms step href was broken** `STEPS[8].href = '../'` resolved through the Link template to the dashboard, not `/admin/forms`. Fixed to `'forms'`. **C3 — EOI signer-identity gate** Folded into the new compound-gate logic on the documenso step (see C1). Now matches what the EOI pipeline actually requires before it can send. **C4 — ensureSystemRoots failure mode poisoned port creation** `ports.service.createPort` awaited `ensureSystemRoots` after the port row had committed, so a throw bubbled out as a 500 even though the inline comment said "non-fatal if this throws". Wrap in try/catch + logger.warn — the row stays live, the next admin action self-heals via `ensureEntityFolder`, and the operator doesn't retry into a 409. **H5 — berth-list empty-state copy misleads fresh ports** "Berths are imported from external sources. Adjust your filters..." implied data existed but was hidden. Branch on whether any filter is active: with none, suggest running `import-berths-from-nocodb.ts`; with filters, the original "adjust filters" message. **M4 — admin-sections-browser description was wrong** "Setup checklist for fresh ports (read-only references)" implied the page was read-only when it has working manual-completion checkboxes and discouraged clicking in. Reworded. Additionally, the OnboardingStep type gains an optional `autoCheckSettingKeysAll` field for compound gates (used by the documenso step), and the auto-detected hint shows all keys when the gate is compound. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -299,7 +299,8 @@ const GROUPS: AdminGroup[] = [
|
||||
{
|
||||
href: 'onboarding',
|
||||
label: 'Onboarding checklist',
|
||||
description: 'Setup checklist for fresh ports (read-only references).',
|
||||
description:
|
||||
'Step-by-step setup checklist for fresh ports — auto-detects what you’ve configured and lets you mark manual steps complete.',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -19,6 +19,11 @@ interface OnboardingStep {
|
||||
* checkmark auto-fills from the settings list. When undefined, the
|
||||
* step relies on the manual checkbox in `onboarding_status`. */
|
||||
autoCheckSettingKey?: string;
|
||||
/** Multi-key gate: all listed setting keys must be present (non-empty)
|
||||
* for the step to auto-complete. Useful for compound checks where a
|
||||
* single key would falsely mark "done" — e.g. Documenso needs a URL
|
||||
* plus signer identity plus a template id, not just the URL. */
|
||||
autoCheckSettingKeysAll?: readonly string[];
|
||||
/** Override: read this many users / tags / roles from a list endpoint
|
||||
* and consider the step done when count > 0. */
|
||||
autoCheckListEndpoint?: string;
|
||||
@@ -38,15 +43,24 @@ const STEPS: OnboardingStep[] = [
|
||||
label: 'Configure outgoing email',
|
||||
description:
|
||||
'From-address, signature, footer, plus per-port SMTP overrides if you don’t use the global account.',
|
||||
autoCheckSettingKey: 'sales_email_smtp_host',
|
||||
autoCheckSettingKey: 'smtp_host_override',
|
||||
},
|
||||
{
|
||||
id: 'documenso',
|
||||
href: 'documenso',
|
||||
label: 'Connect Documenso for EOIs',
|
||||
description:
|
||||
'API credentials and the EOI template id, plus the in-app vs Documenso pathway choice.',
|
||||
autoCheckSettingKey: 'documenso_api_url',
|
||||
'API credentials, the EOI template id, plus the developer + approver identity that signs every EOI.',
|
||||
// Compound gate: an EOI cannot be sent without ALL of these. A
|
||||
// port-admin who saves only the URL would otherwise see the step go
|
||||
// green and discover the gap on first EOI attempt (Documenso 404s
|
||||
// on a missing template, or sends recipients with empty names).
|
||||
autoCheckSettingKeysAll: [
|
||||
'documenso_api_url_override',
|
||||
'documenso_developer_email',
|
||||
'documenso_approver_email',
|
||||
'documenso_eoi_template_id',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
@@ -54,7 +68,10 @@ const STEPS: OnboardingStep[] = [
|
||||
label: 'Tune business rules + recommender weights',
|
||||
description:
|
||||
'Pipeline weights, net-10 discount, berth recommender knobs (heat weights, fall-through policy).',
|
||||
autoCheckSettingKey: 'recommender_top_n_default',
|
||||
// Recommender defaults are layered (port > global > built-in), so a
|
||||
// port that uses the built-ins never writes a row. Use a tuned
|
||||
// heat-weight as the "admin actually saw + chose" sentinel instead.
|
||||
autoCheckSettingKey: 'heat_weight_recency',
|
||||
},
|
||||
{
|
||||
id: 'roles',
|
||||
@@ -88,7 +105,7 @@ const STEPS: OnboardingStep[] = [
|
||||
},
|
||||
{
|
||||
id: 'forms',
|
||||
href: '../',
|
||||
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.',
|
||||
@@ -120,12 +137,19 @@ export function OnboardingChecklist() {
|
||||
const all = [...settings.data.portSettings, ...settings.data.globalSettings];
|
||||
const byKey = new Map(all.map((r) => [r.key, r.value]));
|
||||
|
||||
const isPresent = (v: unknown) => v !== undefined && v !== null && v !== '' && v !== false;
|
||||
|
||||
const checks: Record<string, boolean> = {};
|
||||
const listChecks = await Promise.all(
|
||||
STEPS.map(async (s) => {
|
||||
if (s.autoCheckSettingKey) {
|
||||
const v = byKey.get(s.autoCheckSettingKey);
|
||||
return [s.id, v !== undefined && v !== null && v !== '' && v !== false] as const;
|
||||
return [s.id, isPresent(byKey.get(s.autoCheckSettingKey))] as const;
|
||||
}
|
||||
if (s.autoCheckSettingKeysAll) {
|
||||
return [
|
||||
s.id,
|
||||
s.autoCheckSettingKeysAll.every((k) => isPresent(byKey.get(k))),
|
||||
] as const;
|
||||
}
|
||||
if (s.autoCheckListEndpoint) {
|
||||
try {
|
||||
@@ -226,7 +250,9 @@ export function OnboardingChecklist() {
|
||||
<p className="mt-1 text-[11px] text-emerald-700">
|
||||
Auto-detected complete via{' '}
|
||||
<code className="text-[10px]">
|
||||
{step.autoCheckSettingKey ?? step.autoCheckListEndpoint}
|
||||
{step.autoCheckSettingKey ??
|
||||
step.autoCheckSettingKeysAll?.join(' + ') ??
|
||||
step.autoCheckListEndpoint}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -124,11 +124,23 @@ export function BerthList() {
|
||||
// B2, …) so consecutive rows naturally share dock letters.
|
||||
mobileGroupBy={(row) => row.area ?? 'Unassigned'}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Anchor}
|
||||
title="No berths found"
|
||||
description="Berths are imported from external sources. Adjust your filters to find what you're looking for."
|
||||
/>
|
||||
// Distinguish "no data at all" (fresh port, run the importer)
|
||||
// from "no rows after filtering" (adjust filters). The original
|
||||
// copy implied data existed but was hidden, which misled fresh-
|
||||
// port admins for whom there is literally nothing yet.
|
||||
Object.values(filters).every((v) => v === undefined || v === '') ? (
|
||||
<EmptyState
|
||||
icon={Anchor}
|
||||
title="No berths yet"
|
||||
description="Berths are imported from external sources. Run the importer once the source data is ready: pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug <slug>."
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Anchor}
|
||||
title="No berths match these filters"
|
||||
description="Adjust your filters or clear them to see every berth."
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ports } from '@/lib/db/schema';
|
||||
import type { PortSettings } from '@/lib/db/schema/ports';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { ConflictError, NotFoundError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import type { CreatePortInput, UpdatePortInput } from '@/lib/validators/ports';
|
||||
import { ensureSystemRoots } from '@/lib/services/document-folders.service';
|
||||
@@ -41,9 +42,20 @@ export async function createPort(data: CreatePortInput, meta: AuditMeta) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Non-fatal if this throws: ensureSystemRoots is re-runnable, and
|
||||
// scripts/backfill-document-folders.ts heals orphaned ports.
|
||||
await ensureSystemRoots(port!.id, meta.userId);
|
||||
// Non-fatal if this throws: ensureSystemRoots is re-runnable (any
|
||||
// subsequent admin action self-heals via `ensureEntityFolder`'s
|
||||
// fallback, and `scripts/backfill-document-folders.ts` covers
|
||||
// orphaned ports). Swallow + log instead of propagating, so the
|
||||
// operator doesn't see a 500 from `createPort` against an already-
|
||||
// committed port row — they'd retry and hit a 409 slug-exists.
|
||||
try {
|
||||
await ensureSystemRoots(port!.id, meta.userId);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ portId: port!.id, err },
|
||||
'ensureSystemRoots failed after port create — port row is live; system folders will be created on first admin action.',
|
||||
);
|
||||
}
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
|
||||
Reference in New Issue
Block a user