From 03a752172953068371a98f883bf8f9f4164dbd1d Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 23:02:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch):=20Groups=20J=20+=20K=20?= =?UTF-8?q?=E2=80=94=20activity=20feed=20+=20onboarding=20resolver-chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit J38, J39, K40 (core) from the 2026-05-21 plan. Shipped: J38 EntityActivityFeed sentence rendering surfaces the new value inline. Was " updated the X"; now " set X to " when the audit row carries `newValue`. Field-level diff line underneath keeps showing the old → new strikethrough for context. Truncates inline value at 60 chars to keep long notes / descriptions from blowing out the row. J39 Client → Companies tab CTA. Empty state gains a "Link to a company" action; populated state grows a top-right "Link to company" button. New wraps the existing + a membership-role select + an "is primary" checkbox, then POSTs to /api/v1/companies/[id]/members. Empty-state copy dropped "Add a membership from a company's detail page" — the rep can act inline now. K40 OnboardingChecklist resolver-chain. The auto-check no longer reads raw `/admin/settings` rows (which miss env fallbacks). Resolved endpoint widened to accept `?keys=k1,k2,...` so the checklist can batch-resolve any heterogenous set of registry keys through port → global → env → default in one round-trip. Checklist captures the dominant source per step ("env fallback", "global default", "built-in default") and surfaces it inline under the green tick so super-admins see when a step is relying on env rather than a per-port override. Compound-key gates report the weakest sub-key's source so a partially-env config still flags clearly. Topbar banner / dashboard tile / weekly nudge / celebration sub-items remain queued — the core resolver-chain gap was the actual cause of the "step never ticks" UAT complaint. Verified: tsc clean, vitest 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/v1/admin/settings/resolved/route.ts | 39 ++++- src/components/admin/onboarding-checklist.tsx | 92 ++++++++-- .../clients/client-companies-tab.tsx | 160 +++++++++++++++++- .../shared/entity-activity-feed.tsx | 31 +++- 4 files changed, 293 insertions(+), 29 deletions(-) diff --git a/src/app/api/v1/admin/settings/resolved/route.ts b/src/app/api/v1/admin/settings/resolved/route.ts index fe44f10d..33e15f4a 100644 --- a/src/app/api/v1/admin/settings/resolved/route.ts +++ b/src/app/api/v1/admin/settings/resolved/route.ts @@ -2,16 +2,20 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; -import { entriesForSections } from '@/lib/settings/registry'; +import { entriesForSections, registryFor } from '@/lib/settings/registry'; import { resolveForAdminAPI } from '@/lib/settings/resolver'; /** * GET /api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers + * GET /api/v1/admin/settings/resolved?keys=branding_logo_url,smtp_host_override * * Returns the resolved value + source (port/global/env/default) for every - * registry entry in the requested sections. Drives the registry-driven - * admin form: the `source` field gates the "Using env fallback" badge. + * requested registry entry. Drives both the registry-driven admin form + * (sections param) and the onboarding-checklist auto-detection (keys + * param) — both need port→global→env→default resolution rather than the + * raw `/admin/settings` rows (which only show DB writes). * + * Either parameter is supported; if both are present the sets union. * Sensitive fields surface `isSet` only — never the decrypted value. */ export const GET = withAuth( @@ -19,14 +23,33 @@ export const GET = withAuth( try { const url = new URL(req.url); const sectionsParam = url.searchParams.get('sections'); - if (!sectionsParam) { + const keysParam = url.searchParams.get('keys'); + if (!sectionsParam && !keysParam) { return NextResponse.json({ data: { entries: [], values: {} } }, { status: 200 }); } const sections = sectionsParam - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - const entries = entriesForSections(sections); + ? sectionsParam + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : []; + const extraKeys = keysParam + ? keysParam + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : []; + const sectionEntries = entriesForSections(sections); + const keyEntries = extraKeys + .map((k) => registryFor(k)) + .filter((e): e is NonNullable => Boolean(e)); + // Dedupe by `key` so section + key overlap doesn't double-resolve. + const seen = new Set(); + const entries = [...sectionEntries, ...keyEntries].filter((e) => { + if (seen.has(e.key)) return false; + seen.add(e.key); + return true; + }); const keys = entries.map((e) => e.key); const resolved = await resolveForAdminAPI(keys, ctx.portId); diff --git a/src/components/admin/onboarding-checklist.tsx b/src/components/admin/onboarding-checklist.tsx index e39a2c63..f1ec7da7 100644 --- a/src/components/admin/onboarding-checklist.tsx +++ b/src/components/admin/onboarding-checklist.tsx @@ -112,6 +112,14 @@ const STEPS: OnboardingStep[] = [ }, ]; +interface ResolvedValue { + isSet: boolean; + source?: 'port' | 'global' | 'env' | 'default' | 'none'; + value?: unknown; +} +interface ResolvedResp { + data: { entries: Array<{ key: string }>; values: Record }; +} interface SettingRow { key: string; value: unknown; @@ -125,6 +133,11 @@ export function OnboardingChecklist() { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const [autoChecks, setAutoChecks] = useState>({}); + // Per-step source flags — populated for steps whose auto-check resolved + // via the env / default fallback rather than a port / global override. + // Surfaces "Resolving from env" copy so super admins see what's + // backing each green tick without digging into the settings page. + const [autoSources, setAutoSources] = useState>({}); const [manualChecks, setManualChecks] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); @@ -133,23 +146,40 @@ export function OnboardingChecklist() { async function load() { setLoading(true); try { - const settings = await apiFetch('/api/v1/admin/settings'); - 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; + // Collect every setting key referenced by the checklist so we can + // batch-resolve them through the full chain in one round-trip. + // The `resolved` endpoint reads port→global→env→default, so a + // port using env-only credentials still auto-ticks (the old + // raw `/admin/settings` query missed env fallback entirely). + const keys = new Set(); + for (const s of STEPS) { + if (s.autoCheckSettingKey) keys.add(s.autoCheckSettingKey); + if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k); + } + // Manual-checkbox state still lives in the raw system_settings + // row (it's a JSON blob, not a per-key registry entry) — keep + // fetching it the old way. + const [resolved, settings] = await Promise.all([ + keys.size > 0 + ? apiFetch( + `/api/v1/admin/settings/resolved?keys=${Array.from(keys) + .map(encodeURIComponent) + .join(',')}`, + ) + : Promise.resolve({ data: { entries: [], values: {} } } as ResolvedResp), + apiFetch('/api/v1/admin/settings'), + ]); + const values = resolved.data.values; + const isPresent = (key: string): boolean => Boolean(values[key]?.isSet); const checks: Record = {}; const listChecks = await Promise.all( STEPS.map(async (s) => { if (s.autoCheckSettingKey) { - return [s.id, isPresent(byKey.get(s.autoCheckSettingKey))] as const; + return [s.id, isPresent(s.autoCheckSettingKey)] as const; } if (s.autoCheckSettingKeysAll) { - return [ - s.id, - s.autoCheckSettingKeysAll.every((k) => isPresent(byKey.get(k))), - ] as const; + return [s.id, s.autoCheckSettingKeysAll.every((k) => isPresent(k))] as const; } if (s.autoCheckListEndpoint) { try { @@ -165,7 +195,35 @@ export function OnboardingChecklist() { for (const [id, done] of listChecks) checks[id] = done; setAutoChecks(checks); + // Capture the dominant source per step. For single-key checks this + // is the key's source; for multi-key checks we report the + // "weakest" source so the rep sees env if any sub-key is env. + const sourcesByStep: Record = {}; + const PRIORITY: Record = { + port: 4, + global: 3, + env: 2, + default: 1, + none: 0, + }; + for (const s of STEPS) { + if (s.autoCheckSettingKey) { + sourcesByStep[s.id] = values[s.autoCheckSettingKey]?.source; + } else if (s.autoCheckSettingKeysAll) { + const sources = s.autoCheckSettingKeysAll + .map((k) => values[k]?.source) + .filter((x): x is NonNullable => Boolean(x)); + // Pick the lowest-priority (weakest) source so the rep sees + // "env" if the compound has any env-only sub-key. + sources.sort((a, b) => (PRIORITY[a] ?? 0) - (PRIORITY[b] ?? 0)); + sourcesByStep[s.id] = sources[0]; + } + } + setAutoSources(sourcesByStep); + // Pull the manual-checkbox state from system_settings. + const allSettings = [...settings.data.portSettings, ...settings.data.globalSettings]; + const byKey = new Map(allSettings.map((r) => [r.key, r.value])); const manual = (byKey.get('onboarding_manual_status') ?? {}) as Record; setManualChecks(manual); } finally { @@ -254,6 +312,20 @@ export function OnboardingChecklist() { step.autoCheckSettingKeysAll?.join(' + ') ?? step.autoCheckListEndpoint} + {autoSources[step.id] && autoSources[step.id] !== 'port' ? ( + + · resolving from{' '} + + {autoSources[step.id] === 'env' + ? 'env fallback' + : autoSources[step.id] === 'global' + ? 'global default' + : autoSources[step.id] === 'default' + ? 'built-in default' + : autoSources[step.id]} + + + ) : null}

)} diff --git a/src/components/clients/client-companies-tab.tsx b/src/components/clients/client-companies-tab.tsx index ada6f283..18be40b2 100644 --- a/src/components/clients/client-companies-tab.tsx +++ b/src/components/clients/client-companies-tab.tsx @@ -1,8 +1,12 @@ 'use client'; +import { useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { format } from 'date-fns'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Building2, Plus } from 'lucide-react'; +import { toast } from 'sonner'; import { Table, @@ -13,7 +17,27 @@ import { TableRow, } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { EmptyState } from '@/components/shared/empty-state'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { CompanyPicker } from '@/components/companies/company-picker'; +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface ClientCompaniesTabProps { clientId: string; @@ -37,22 +61,45 @@ function formatSince(startDate: string | Date): string { return format(d, 'MMM d, yyyy'); } -export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCompaniesTabProps) { +export function ClientCompaniesTab({ clientId, companies }: ClientCompaniesTabProps) { const routeParams = useParams<{ portSlug: string }>(); const portSlug = routeParams?.portSlug ?? ''; + const [linkOpen, setLinkOpen] = useState(false); if (companies.length === 0) { return ( - + <> + setLinkOpen(true) }} + /> + + ); } return (
-

Company affiliations

+
+

Company affiliations

+ +
+
@@ -101,3 +148,104 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom ); } + +interface LinkCompanyDialogProps { + open: boolean; + onOpenChange: (next: boolean) => void; + clientId: string; + portSlug: string; +} + +const MEMBERSHIP_ROLES = [ + { value: 'director', label: 'Director' }, + { value: 'shareholder', label: 'Shareholder' }, + { value: 'employee', label: 'Employee' }, + { value: 'agent', label: 'Agent' }, + { value: 'beneficial_owner', label: 'Beneficial owner' }, + { value: 'authorised_signatory', label: 'Authorised signatory' }, + { value: 'other', label: 'Other' }, +]; + +function LinkCompanyDialog({ open, onOpenChange, clientId, portSlug }: LinkCompanyDialogProps) { + const [companyId, setCompanyId] = useState(null); + const [role, setRole] = useState('director'); + const [isPrimary, setIsPrimary] = useState(false); + const qc = useQueryClient(); + + const create = useMutation({ + mutationFn: async () => { + if (!companyId) throw new Error('Pick a company'); + await apiFetch(`/api/v1/companies/${companyId}/members`, { + method: 'POST', + body: { clientId, role, isPrimary }, + }); + }, + onSuccess: () => { + toast.success('Membership added'); + void qc.invalidateQueries({ queryKey: ['clients', clientId] }); + setCompanyId(null); + setRole('director'); + setIsPrimary(false); + onOpenChange(false); + }, + onError: (err) => toastError(err), + }); + + return ( + + + + Link client to a company + + Pick an existing company or{' '} + + create a new one + + , then choose this client's role in it. + + +
+
+ + +
+
+ + +
+ +
+ + + + +
+
+ ); +} diff --git a/src/components/shared/entity-activity-feed.tsx b/src/components/shared/entity-activity-feed.tsx index a0751b78..37e1f14b 100644 --- a/src/components/shared/entity-activity-feed.tsx +++ b/src/components/shared/entity-activity-feed.tsx @@ -65,15 +65,36 @@ function formatValueForField(field: string | null, value: unknown): string { return JSON.stringify(value); } -/** Natural-sentence rendering of one audit row. Falls back to terse when - * there's no field to talk about. */ +/** Natural-sentence rendering of one audit row. + * + * Behaviour ladder: + * 1. Field changed with both old + new values → "set to " + * (the inline diff below already shows the old-value strikethrough, + * so we don't restate it here; result reads like a sentence rather + * than a diff label). + * 2. Field changed with only a new value → "set to " + * 3. Field changed with neither value (rare; legacy rows) → " + * the " + * 4. No field → " this record" + * + * Truncation at 60 chars on the inline value keeps long body fields + * (notes, descriptions) from blowing out the row — the diff line + * below still renders the full value if the rep clicks through. + */ function sentence(row: AuditRow, actor: string): string { const verb = actionVerb(row.action); const field = formatField(row.fieldChanged); - if (field) { - return `${actor} ${verb} the ${field}`; + if (!field) return `${actor} ${verb} this record`; + + const newFormatted = + row.newValue !== null && row.newValue !== undefined + ? formatValueForField(row.fieldChanged, row.newValue) + : null; + if (newFormatted) { + const truncated = newFormatted.length > 60 ? newFormatted.slice(0, 60) + '…' : newFormatted; + return `${actor} set ${field} to "${truncated}"`; } - return `${actor} ${verb} this record`; + return `${actor} ${verb} the ${field}`; } interface Props {