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:
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user