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:
2026-05-25 03:40:37 +02:00
parent 41737fa950
commit 14ae41d0fa
40 changed files with 835 additions and 70 deletions

View File

@@ -0,0 +1,7 @@
-- Backfill: ensure every primary interest_berths row carries is_in_eoi_bundle=true.
-- The service layer now enforces this invariant on insert + update, but
-- historical rows from before the guard could still violate it.
UPDATE interest_berths
SET is_in_eoi_bundle = TRUE
WHERE is_primary = TRUE
AND is_in_eoi_bundle = FALSE;

View File

@@ -293,12 +293,23 @@ export async function upsertInterestBerthTx(
if (opts.isInEoiBundle !== undefined) setForUpdate.isInEoiBundle = opts.isInEoiBundle;
// Invariant: primary berth is ALWAYS in the EOI bundle. The primary IS
// the canonical "berth for this deal" - excluding it from the signed
// envelope is semantically nonsense. If the caller is setting the row
// to primary OR opting to take out of the EOI bundle, force the bundle
// flag back on whenever the row is also (about to be) primary.
// envelope is semantically nonsense.
// • If the caller is setting the row to primary + opting out of bundle,
// force the bundle flag back on.
// • If the existing row is already primary and the caller is toggling
// the bundle off without changing primary, also force it back on.
const willBePrimary = opts.isPrimary === true;
if (willBePrimary && opts.isInEoiBundle === false) {
setForUpdate.isInEoiBundle = true;
} else if (opts.isInEoiBundle === false && opts.isPrimary !== false) {
const existing = await tx
.select({ isPrimary: interestBerths.isPrimary })
.from(interestBerths)
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)))
.limit(1);
if (existing[0]?.isPrimary) {
setForUpdate.isInEoiBundle = true;
}
}
if (opts.addedBy !== undefined) setForUpdate.addedBy = opts.addedBy;
if (opts.notes !== undefined) setForUpdate.notes = opts.notes;

View File

@@ -10,7 +10,7 @@ import { berthReservations } from '@/lib/db/schema/reservations';
import { yachts } from '@/lib/db/schema/yachts';
import { companyMemberships } from '@/lib/db/schema/companies';
import { tags } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
import { userProfiles, userPortRoles, roles } from '@/lib/db/schema/users';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { getPortReminderConfig } from '@/lib/services/port-config';
@@ -755,14 +755,25 @@ export async function createInterest(portId: string, data: CreateInterestInput,
if (v?.userId) {
resolvedAssignedTo = v.userId;
} else {
// Tier 3: auto-assign to creator unless they're a super-admin.
// Tier 3: auto-assign to creator only when their role is a working
// sales rep — super_admin / director / residential_partner / viewer
// intentionally skip (they create on behalf of others or shouldn't
// own interests at all).
const AUTO_ASSIGN_ROLES = new Set(['sales_agent', 'sales_manager']);
const [profile] = await db
.select({ isSuperAdmin: userProfiles.isSuperAdmin })
.from(userProfiles)
.where(eq(userProfiles.userId, meta.userId))
.limit(1);
if (profile && !profile.isSuperAdmin) {
resolvedAssignedTo = meta.userId;
const userRoles = await db
.select({ name: roles.name })
.from(userPortRoles)
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
.where(and(eq(userPortRoles.userId, meta.userId), eq(userPortRoles.portId, portId)));
if (userRoles.some((r) => AUTO_ASSIGN_ROLES.has(r.name))) {
resolvedAssignedTo = meta.userId;
}
}
}
}

View 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 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,
};
}

View File

@@ -37,10 +37,10 @@ const SUPPORTED_SET: ReadonlySet<string> = new Set(SUPPORTED_CURRENCIES.map((c)
* null/undefined (returns the empty string for the latter so callers
* can short-circuit display logic).
*
* Defaults to `en-US` locale because the CRM is single-locale today;
* pass `locale` explicitly when rendering for portal users in the
* future. Unknown ISO codes fall through to Intl unchanged so the
* function never throws on legacy data.
* When `options.locale` is omitted the runtime's default locale is used
* (Intl falls back to the browser/Node default). Pass `locale` explicitly
* to force a specific rendering. Unknown ISO codes fall through to Intl
* unchanged so the function never throws on legacy data.
*/
export function formatCurrency(
amount: number | string | null | undefined,
@@ -60,7 +60,7 @@ export function formatCurrency(
// `toLocaleString` throws "Computed minimumFractionDigits is larger
// than maximumFractionDigits". The same defensive clamp protects
// Intl.NumberFormat too.
const { locale = 'en-US', maxFractionDigits = 2 } = options;
const { locale, maxFractionDigits = 2 } = options;
const minFractionDigits = options.minFractionDigits ?? Math.min(2, maxFractionDigits);
try {
return new Intl.NumberFormat(locale, {