Compendium of polish + small-fix work captured during the 2026-05-26
live UAT session. Every change has a corresponding entry in
docs/superpowers/audits/active-uat.md with file:line evidence + root
cause + alternatives considered.
Dialog primitive width
- DialogContent default bumped from sm:max-w-lg (512px) to
sm:max-w-xl + lg:max-w-3xl so every consumer gets a sane desktop
default. Confirm dialogs override DOWN, content-heavy dialogs
override UP.
- FilePreviewDialog full-viewport via w-[min(95vw,1400px)] +
h-[85vh] so PDFs render at usable width on real desktops.
Recommender card
- Heat badge now a Popover with the score (X/100), the formula in
plain English, the four component breakdowns (recency / furthest
stage / interest count / EOI count), and a pointer to the admin
weight tuning page.
- Area letter span dropped from the card header - mooring number
already prefixes it.
- BerthRecommenderPanel + the dedicated "Berth Recommendations" tab
both hidden when interest.desiredLengthFt is null. The empty
guidance card was reading as noise. interest-tabs.tsx computes
hasDesiredDims once and gates the inline mount + tab strip
spread off it.
BerthPicker
- Drop area suffix from row labels. Mooring number already carries
the area letter prefix; group heading conveys the same context.
Same fix flows to every BerthPicker consumer (tenancy
create/renew/transfer, interest form, linked-berths picker).
CreateDocumentWizard
- DOCUMENT_TYPE_LABELS constant added to constants.ts. Wizard reads
from the map instead of naive replace(/_/g, ' '): "EOI",
"Contract", "NDA", "Reservation Agreement", "Other".
- "Other" option surfaces a hint pointing the rep at the Title
field so they describe what the doc actually is.
InterestForm inline client + yacht create
- ClientForm gains an onCreated(clientId) callback. Mutation
returns { id } in create mode so onSuccess can forward.
- InterestForm renders an "Add new" Button next to the Client label
(create mode only - hidden on edit), opens ClientForm, auto-
selects the new client into the draft. Mirrors the existing
inline yacht-create pattern.
- Reset path includes source: 'manual' alongside the other create-
mode defaults; the manual flow was dropping back to a blank
source dropdown on reopen.
Tenancy list
- ClientTenanciesTab activeTenancies query now includes status
IN ('pending', 'active'). Was filtering to active-only; pending
rows from manual create + webhook auto-create were invisible on
the client detail's Tenancies tab.
- TenancyList rows are now keyboard- and click-navigable to the
tenancy detail page (Enter/Space included). Inner links + buttons
stop propagation so per-cell navigation works.
NotesList source badge
- Aggregated-mode source badge ("Yacht / Test Yacht") is now a Link
to the source entity's detail page. New sourceLinkFor helper
centralises the URL mapping across clients/companies/yachts/
interests + residential variants.
Yacht transfer audit log
- transferOwnership emits a distinct 'transfer' AuditAction (added
to AuditAction union in src/lib/audit.ts) with old/new owner
names resolved at write time. EntityActivityFeed renders
"Matt transferred owner to Jane Smith" instead of "Matt updated
this record." formatValueForField unwraps the { name } shape so
the audit_logs Record<string, unknown> typing stays clean.
- yacht-transfer-dialog copy: dropped "atomic" jargon. Reads "The
change is logged in the audit history" instead.
Companies autocomplete
- /api/v1/companies/autocomplete now returns the 10 most-recently-
updated companies when the query string is empty. Was returning
[]. CompanyPicker popover opens with results to scan instead of a
blank dropdown.
DocumentsHub FlatFolderListing
- Uploaded files (the files table) now merge into the documents
table view via a parallel /api/v1/files?folderId=X query +
client-side merge into a unified row list. listFiles service
honours the folderId filter that was already accepted by the
validator. New renderFileRow renders file rows with an "Uploaded
file" type pill + "Stored" status pill, links the filename to
the download URL. Existing FolderDropZone invalidation covers
the new query, so drag-drop and New-document-menu uploads
refresh the list without a page reload.
- FlatFolderListing wrapped in a vertically-spaced container so
subfolders / search row / list have consistent gap.
- Per-row chevron only renders when totalSigners > 0; empty
placeholder column kept so grid alignment doesn't jump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1140 lines
38 KiB
TypeScript
1140 lines
38 KiB
TypeScript
import { and, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import {
|
|
clients,
|
|
clientContacts,
|
|
clientNotes,
|
|
clientRelationships,
|
|
clientTags,
|
|
clientAddresses,
|
|
} from '@/lib/db/schema/clients';
|
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
|
import { yachts } from '@/lib/db/schema/yachts';
|
|
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
|
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
|
import { berths } from '@/lib/db/schema/berths';
|
|
import { tags } from '@/lib/db/schema/system';
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
|
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
|
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
|
import { emitToRoom } from '@/lib/socket/server';
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
import { diffEntity } from '@/lib/entity-diff';
|
|
import { restore, withTransaction } from '@/lib/db/utils';
|
|
import { logger } from '@/lib/logger';
|
|
import {
|
|
syncEntityFolderName,
|
|
applyEntityArchivedSuffix,
|
|
applyEntityRestoredSuffix,
|
|
} from '@/lib/services/document-folders.service';
|
|
import type {
|
|
CreateClientInput,
|
|
UpdateClientInput,
|
|
ListClientsInput,
|
|
} from '@/lib/validators/clients';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function listClients(portId: string, query: ListClientsInput) {
|
|
const { page, limit, sort, order, search, includeArchived, source, nationality, tagIds } = query;
|
|
|
|
const filters = [];
|
|
|
|
if (source) {
|
|
filters.push(eq(clients.source, source));
|
|
}
|
|
if (nationality) {
|
|
// Filter accepts an ISO-3166-1 alpha-2 code.
|
|
filters.push(eq(clients.nationalityIso, nationality.toUpperCase()));
|
|
}
|
|
if (tagIds && tagIds.length > 0) {
|
|
const clientsWithTags = await db
|
|
.selectDistinct({ clientId: clientTags.clientId })
|
|
.from(clientTags)
|
|
.where(inArray(clientTags.tagId, tagIds));
|
|
const matchingIds = clientsWithTags.map((r) => r.clientId);
|
|
if (matchingIds.length > 0) {
|
|
filters.push(inArray(clients.id, matchingIds));
|
|
} else {
|
|
// No clients match these tags - return empty
|
|
return { data: [], total: 0 };
|
|
}
|
|
}
|
|
|
|
let sortColumn: typeof clients.fullName | typeof clients.createdAt | typeof clients.updatedAt =
|
|
clients.updatedAt;
|
|
if (sort === 'fullName') sortColumn = clients.fullName;
|
|
else if (sort === 'createdAt') sortColumn = clients.createdAt;
|
|
|
|
const result = await buildListQuery<typeof clients.$inferSelect>({
|
|
table: clients,
|
|
portIdColumn: clients.portId,
|
|
portId,
|
|
idColumn: clients.id,
|
|
updatedAtColumn: clients.updatedAt,
|
|
searchColumns: [clients.fullName],
|
|
searchTerm: search,
|
|
filters,
|
|
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
|
page,
|
|
pageSize: limit,
|
|
includeArchived,
|
|
archivedAtColumn: clients.archivedAt,
|
|
});
|
|
|
|
if (result.data.length === 0) return result;
|
|
|
|
const ids = result.data.map((r) => r.id);
|
|
|
|
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows, linkedBerthRows] =
|
|
await Promise.all([
|
|
db
|
|
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
|
.from(yachts)
|
|
.where(
|
|
and(
|
|
eq(yachts.portId, portId),
|
|
eq(yachts.currentOwnerType, 'client'),
|
|
inArray(yachts.currentOwnerId, ids),
|
|
isNull(yachts.archivedAt),
|
|
),
|
|
)
|
|
.groupBy(yachts.currentOwnerId),
|
|
db
|
|
.select({ clientId: companyMemberships.clientId, count: count() })
|
|
.from(companyMemberships)
|
|
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
|
|
.groupBy(companyMemberships.clientId),
|
|
// Latest interest per client + its primary-berth mooring (resolved via
|
|
// interest_berths join, plan §3.4). The is_primary filter narrows the
|
|
// join to ≤1 berth row per interest; non-primary links never surface
|
|
// through this list-page derivation.
|
|
db
|
|
.select({
|
|
clientId: interests.clientId,
|
|
pipelineStage: interests.pipelineStage,
|
|
updatedAt: interests.updatedAt,
|
|
mooringNumber: berths.mooringNumber,
|
|
})
|
|
.from(interests)
|
|
.leftJoin(
|
|
interestBerths,
|
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
|
)
|
|
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
|
|
.where(
|
|
and(
|
|
eq(interests.portId, portId),
|
|
inArray(interests.clientId, ids),
|
|
isNull(interests.archivedAt),
|
|
),
|
|
)
|
|
.orderBy(desc(interests.updatedAt)),
|
|
db
|
|
.select({ clientId: interests.clientId, count: count() })
|
|
.from(interests)
|
|
.where(
|
|
and(
|
|
eq(interests.portId, portId),
|
|
inArray(interests.clientId, ids),
|
|
isNull(interests.archivedAt),
|
|
),
|
|
)
|
|
.groupBy(interests.clientId),
|
|
// Pull at most ONE contact per (client_id, channel) for the page.
|
|
// DISTINCT ON sorted by `is_primary DESC, created_at DESC` keeps
|
|
// the picker logic identical to the in-memory version it replaced
|
|
// while bounding the row count to ~2 per client (one email, one
|
|
// phone) regardless of how many contacts the client has.
|
|
db.execute<{
|
|
clientId: string;
|
|
channel: string;
|
|
value: string;
|
|
valueE164: string | null;
|
|
isPrimary: boolean;
|
|
createdAt: Date;
|
|
}>(sql`
|
|
SELECT DISTINCT ON (client_id, channel)
|
|
client_id AS "clientId",
|
|
channel,
|
|
value,
|
|
value_e164 AS "valueE164",
|
|
is_primary AS "isPrimary",
|
|
created_at AS "createdAt"
|
|
FROM client_contacts
|
|
WHERE ${inArray(clientContacts.clientId, ids)}
|
|
AND channel IN ('email', 'phone')
|
|
ORDER BY client_id, channel, is_primary DESC, created_at DESC
|
|
`),
|
|
// Berths each client has interests in, with the (most-active)
|
|
// interest's stage attached so the list-view chip can self-describe
|
|
// ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON
|
|
// collapses (client, berth) when the client has had multiple
|
|
// historical interests in the same berth - we keep the open-outcome
|
|
// one if any, otherwise the most recently updated. Excludes archived
|
|
// interests so closed deals don't crowd the chip row.
|
|
db.execute<{
|
|
clientId: string;
|
|
berthId: string;
|
|
mooringNumber: string;
|
|
interestId: string;
|
|
pipelineStage: string;
|
|
outcome: string | null;
|
|
}>(sql`
|
|
SELECT DISTINCT ON (i.client_id, b.id)
|
|
i.client_id AS "clientId",
|
|
b.id AS "berthId",
|
|
b.mooring_number AS "mooringNumber",
|
|
i.id AS "interestId",
|
|
i.pipeline_stage AS "pipelineStage",
|
|
i.outcome
|
|
FROM interests i
|
|
JOIN interest_berths ib ON ib.interest_id = i.id
|
|
JOIN berths b ON b.id = ib.berth_id
|
|
WHERE i.port_id = ${portId}
|
|
AND i.client_id IN (${sql.join(
|
|
ids.map((id) => sql`${id}`),
|
|
sql`, `,
|
|
)})
|
|
AND i.archived_at IS NULL
|
|
ORDER BY
|
|
i.client_id,
|
|
b.id,
|
|
CASE WHEN i.outcome IS NULL THEN 0 ELSE 1 END,
|
|
i.updated_at DESC
|
|
`),
|
|
]);
|
|
|
|
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
|
|
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
|
|
const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count]));
|
|
// interestRows is sorted desc by updatedAt; first hit per clientId is the latest.
|
|
const latestInterestMap = new Map<string, { stage: string; mooringNumber: string | null }>();
|
|
for (const row of interestRows) {
|
|
if (!latestInterestMap.has(row.clientId)) {
|
|
latestInterestMap.set(row.clientId, {
|
|
stage: row.pipelineStage,
|
|
mooringNumber: row.mooringNumber,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Pick the per-client primary email + phone. The SQL DISTINCT ON
|
|
// returns at most one row per (clientId, channel); the result is
|
|
// already the picker's "is_primary desc, created_at desc" choice.
|
|
// We also keep the E.164 form of the phone so the UI can build a
|
|
// wa.me/<digits> link that doesn't need re-parsing.
|
|
const primaryEmailMap = new Map<string, string>();
|
|
const primaryPhoneMap = new Map<string, string>();
|
|
const primaryPhoneE164Map = new Map<string, string>();
|
|
type ContactRow = {
|
|
clientId: string;
|
|
channel: string;
|
|
value: string;
|
|
valueE164: string | null;
|
|
isPrimary: boolean;
|
|
createdAt: Date;
|
|
};
|
|
const contactRowList: ContactRow[] =
|
|
(contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]);
|
|
for (const c of contactRowList) {
|
|
if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value);
|
|
else if (c.channel === 'phone') {
|
|
primaryPhoneMap.set(c.clientId, c.value);
|
|
if (c.valueE164) primaryPhoneE164Map.set(c.clientId, c.valueE164);
|
|
}
|
|
}
|
|
|
|
// Aggregate berths per client, sorted so the most-action-worthy
|
|
// interest floats to the top of the chip row. Priority:
|
|
// 1. open outcome (active deal) before closed (won/lost/cancelled)
|
|
// 2. within open: most progressed stage first (contract > … > enquiry)
|
|
// 3. tie-breaker: mooring number alphabetical for stable ordering
|
|
// The list-view UI shows the top 2 with full labels; the rest fall
|
|
// through into a "+N more" popover.
|
|
//
|
|
// L-001 fix: pre-refactor this map used the 9-stage legacy names
|
|
// (contract_signed, deposit_10pct, …) and every modern 7-stage value
|
|
// fell through to rank 0, making the sort effectively random for any
|
|
// post-refactor interest. Modern values now own the canonical ranks
|
|
// and legacy keys map to their 7-stage equivalents so historical data
|
|
// continues to sort correctly.
|
|
const stageRank: Record<string, number> = {
|
|
// modern (post 9→7 refactor)
|
|
contract: 1,
|
|
deposit_paid: 2,
|
|
reservation: 3,
|
|
eoi: 4,
|
|
nurturing: 5,
|
|
qualified: 6,
|
|
enquiry: 7,
|
|
// legacy aliases - kept so audit-log + soft-archive data sorts the same
|
|
contract_signed: 1,
|
|
contract_sent: 1,
|
|
completed: 1,
|
|
deposit_10pct: 2,
|
|
eoi_signed: 4,
|
|
eoi_sent: 4,
|
|
in_communication: 6,
|
|
details_sent: 7,
|
|
open: 7,
|
|
};
|
|
type LinkedBerth = {
|
|
id: string;
|
|
mooringNumber: string;
|
|
interestId: string;
|
|
stage: string;
|
|
outcome: string | null;
|
|
};
|
|
const linkedBerthsMap = new Map<string, LinkedBerth[]>();
|
|
type LinkedBerthRow = typeof linkedBerthRows extends Iterable<infer T> ? T : never;
|
|
const linkedBerthList: LinkedBerthRow[] =
|
|
(linkedBerthRows as { rows?: LinkedBerthRow[] }).rows ??
|
|
(linkedBerthRows as unknown as LinkedBerthRow[]);
|
|
for (const r of linkedBerthList) {
|
|
const list = linkedBerthsMap.get(r.clientId) ?? [];
|
|
list.push({
|
|
id: r.berthId,
|
|
mooringNumber: r.mooringNumber,
|
|
interestId: r.interestId,
|
|
stage: r.pipelineStage,
|
|
outcome: r.outcome,
|
|
});
|
|
linkedBerthsMap.set(r.clientId, list);
|
|
}
|
|
for (const list of linkedBerthsMap.values()) {
|
|
list.sort((a, b) => {
|
|
// Open before closed.
|
|
const openA = a.outcome === null ? 0 : 1;
|
|
const openB = b.outcome === null ? 0 : 1;
|
|
if (openA !== openB) return openA - openB;
|
|
// Within bucket, most-progressed stage first.
|
|
const rankA = stageRank[a.stage] ?? 99;
|
|
const rankB = stageRank[b.stage] ?? 99;
|
|
if (rankA !== rankB) return rankA - rankB;
|
|
return a.mooringNumber.localeCompare(b.mooringNumber);
|
|
});
|
|
}
|
|
|
|
return {
|
|
...result,
|
|
data: result.data.map((row) => {
|
|
const latest = latestInterestMap.get(row.id);
|
|
return {
|
|
...row,
|
|
yachtCount: yachtCountMap.get(row.id) ?? 0,
|
|
companyCount: companyCountMap.get(row.id) ?? 0,
|
|
interestCount: interestCountMap.get(row.id) ?? 0,
|
|
primaryEmail: primaryEmailMap.get(row.id) ?? null,
|
|
primaryPhone: primaryPhoneMap.get(row.id) ?? null,
|
|
primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null,
|
|
linkedBerths: linkedBerthsMap.get(row.id) ?? [],
|
|
latestInterest: latest
|
|
? {
|
|
stage: latest.stage,
|
|
mooringNumber: latest.mooringNumber,
|
|
}
|
|
: null,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
|
|
|
export async function getClientById(id: string, portId: string) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, id),
|
|
});
|
|
|
|
if (!client || client.portId !== portId) {
|
|
throw new NotFoundError('Client');
|
|
}
|
|
|
|
const contacts = await db.query.clientContacts.findMany({
|
|
where: eq(clientContacts.clientId, id),
|
|
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
|
});
|
|
|
|
const addresses = await db.query.clientAddresses.findMany({
|
|
where: eq(clientAddresses.clientId, id),
|
|
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
|
});
|
|
|
|
const clientTagRows = await db
|
|
.select({ tag: tags })
|
|
.from(clientTags)
|
|
.innerJoin(tags, eq(clientTags.tagId, tags.id))
|
|
.where(eq(clientTags.clientId, id));
|
|
|
|
const yachtRows = await db.query.yachts.findMany({
|
|
where: and(
|
|
eq(yachts.portId, portId),
|
|
eq(yachts.currentOwnerType, 'client'),
|
|
eq(yachts.currentOwnerId, id),
|
|
isNull(yachts.archivedAt),
|
|
),
|
|
columns: {
|
|
id: true,
|
|
name: true,
|
|
hullNumber: true,
|
|
registration: true,
|
|
lengthFt: true,
|
|
widthFt: true,
|
|
status: true,
|
|
},
|
|
});
|
|
|
|
const membershipRows = await db
|
|
.select({
|
|
membershipId: companyMemberships.id,
|
|
role: companyMemberships.role,
|
|
isPrimary: companyMemberships.isPrimary,
|
|
startDate: companyMemberships.startDate,
|
|
company: {
|
|
id: companies.id,
|
|
name: companies.name,
|
|
legalName: companies.legalName,
|
|
status: companies.status,
|
|
},
|
|
})
|
|
.from(companyMemberships)
|
|
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.clientId, id),
|
|
eq(companies.portId, portId),
|
|
isNull(companyMemberships.endDate),
|
|
),
|
|
);
|
|
|
|
// Include pending tenancies alongside active ones — a tenancy starts
|
|
// in `pending` (auto-created from a signed Reservation Agreement, or
|
|
// manually created via the "Create tenancy" button) and stays pending
|
|
// until the rep confirms start date + tenure type via the
|
|
// pending→active activation flow. Reps need to SEE pending rows on
|
|
// the client tab to act on them; only filtering to `active` hid the
|
|
// freshly-created tenancy entirely (UAT 2026-05-26).
|
|
const activeTenancies = await db.query.berthTenancies.findMany({
|
|
where: and(
|
|
eq(berthTenancies.clientId, id),
|
|
eq(berthTenancies.portId, portId),
|
|
inArray(berthTenancies.status, ['pending', 'active']),
|
|
),
|
|
columns: {
|
|
id: true,
|
|
berthId: true,
|
|
yachtId: true,
|
|
startDate: true,
|
|
tenureType: true,
|
|
status: true,
|
|
},
|
|
});
|
|
|
|
const portalEnabled = await isPortalEnabledForPort(portId);
|
|
|
|
// Counts surfaced for tab badges (Interests + Notes - Yachts/Companies/etc
|
|
// get their counts from the corresponding row arrays we already fetched).
|
|
const [interestCountRow] = await db
|
|
.select({ count: count() })
|
|
.from(interests)
|
|
.where(
|
|
and(eq(interests.portId, portId), eq(interests.clientId, id), isNull(interests.archivedAt)),
|
|
);
|
|
const [noteCountRow] = await db
|
|
.select({ count: count() })
|
|
.from(clientNotes)
|
|
.where(eq(clientNotes.clientId, id));
|
|
|
|
return {
|
|
...client,
|
|
contacts,
|
|
addresses,
|
|
tags: clientTagRows.map((r) => r.tag),
|
|
yachts: yachtRows,
|
|
companies: membershipRows,
|
|
activeTenancies,
|
|
interestCount: interestCountRow?.count ?? 0,
|
|
noteCount: noteCountRow?.count ?? 0,
|
|
clientPortalEnabled: portalEnabled,
|
|
};
|
|
}
|
|
|
|
// ─── Create ───────────────────────────────────────────────────────────────────
|
|
|
|
export async function createClient(portId: string, data: CreateClientInput, meta: AuditMeta) {
|
|
const result = await withTransaction(async (tx) => {
|
|
const { contacts: contactsInput, tagIds, ...clientData } = data;
|
|
|
|
const [client] = await tx
|
|
.insert(clients)
|
|
.values({ portId, ...clientData })
|
|
.returning();
|
|
|
|
if (contactsInput.length > 0) {
|
|
await tx
|
|
.insert(clientContacts)
|
|
.values(contactsInput.map((c) => ({ clientId: client!.id, ...c })));
|
|
}
|
|
|
|
if (tagIds && tagIds.length > 0) {
|
|
await tx.insert(clientTags).values(tagIds.map((tagId) => ({ clientId: client!.id, tagId })));
|
|
}
|
|
|
|
return client!;
|
|
});
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'create',
|
|
entityType: 'client',
|
|
entityId: result.id,
|
|
newValue: { fullName: result.fullName },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'client:created', {
|
|
clientId: result.id,
|
|
clientName: result.fullName ?? '',
|
|
source: result.source ?? '',
|
|
});
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
dispatchWebhookEvent(portId, 'client:created', { clientId: result.id }),
|
|
);
|
|
|
|
return result;
|
|
}
|
|
|
|
// ─── Update ───────────────────────────────────────────────────────────────────
|
|
|
|
export async function updateClient(
|
|
id: string,
|
|
portId: string,
|
|
data: UpdateClientInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await db.query.clients.findFirst({
|
|
where: eq(clients.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Client');
|
|
}
|
|
|
|
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
|
|
|
|
const [updated] = await db
|
|
.update(clients)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(and(eq(clients.id, id), eq(clients.portId, portId)))
|
|
.returning();
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'client',
|
|
entityId: id,
|
|
oldValue: diff as Record<string, unknown>,
|
|
newValue: data as Record<string, unknown>,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', {
|
|
clientId: id,
|
|
changedFields: Object.keys(diff),
|
|
});
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
dispatchWebhookEvent(portId, 'client:updated', { clientId: id }),
|
|
);
|
|
|
|
if (data.fullName !== undefined) {
|
|
await syncEntityFolderName(portId, 'client', id, meta.userId).catch((err) => {
|
|
logger.warn({ err, clientId: id }, 'Failed to sync client folder name');
|
|
});
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
// ─── Archive / Restore ────────────────────────────────────────────────────────
|
|
|
|
export async function archiveClient(id: string, portId: string, meta: AuditMeta) {
|
|
const existing = await db.query.clients.findFirst({
|
|
where: eq(clients.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Client');
|
|
}
|
|
|
|
// F10: cascade-archive the client's open interests so they don't
|
|
// dangle in active queries with a shadowed client. Won/lost interests
|
|
// (outcome IS NOT NULL) are kept as historical records - only IN-FLIGHT
|
|
// deals get archived. Wrapped in a single transaction so a partial
|
|
// archive can't leave the system half-cascaded.
|
|
const archivedInterestIds: string[] = await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(clients)
|
|
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
|
.where(eq(clients.id, id));
|
|
const cascaded = await tx
|
|
.update(interests)
|
|
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(interests.clientId, id),
|
|
eq(interests.portId, portId),
|
|
isNull(interests.archivedAt),
|
|
isNull(interests.outcome),
|
|
),
|
|
)
|
|
.returning({ id: interests.id });
|
|
return cascaded.map((r) => r.id);
|
|
});
|
|
|
|
// fire-and-forget: archive UI does not depend on the folder suffix
|
|
// being stamped before the HTTP response returns. Task 5 (rename
|
|
// hook) uses await because the rename should be visible to the
|
|
// next read; archive does not.
|
|
void applyEntityArchivedSuffix(portId, 'client', id, meta.userId).catch((err) => {
|
|
logger.warn({ err, clientId: id, portId }, 'Failed to apply archived suffix to client folder');
|
|
});
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'archive',
|
|
entityType: 'client',
|
|
entityId: id,
|
|
// Surface the cascade in the audit trail so /admin/audit shows
|
|
// exactly which interests got swept up.
|
|
newValue:
|
|
archivedInterestIds.length > 0 ? { cascadedInterestIds: archivedInterestIds } : undefined,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
// H-07: emit per-interest archive rows so an auditor searching for a
|
|
// specific archived interest finds it directly - the client-level row's
|
|
// `cascadedInterestIds` array doesn't participate in audit-log FTS.
|
|
for (const interestId of archivedInterestIds) {
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'archive',
|
|
entityType: 'interest',
|
|
entityId: interestId,
|
|
metadata: { cascadeSource: 'client_archive', clientId: id },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
}
|
|
|
|
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
|
|
for (const interestId of archivedInterestIds) {
|
|
emitToRoom(`port:${portId}`, 'interest:archived', { interestId });
|
|
}
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
dispatchWebhookEvent(portId, 'client:archived', { clientId: id }),
|
|
);
|
|
}
|
|
|
|
export async function restoreClient(id: string, portId: string, meta: AuditMeta) {
|
|
const existing = await db.query.clients.findFirst({
|
|
where: eq(clients.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Client');
|
|
}
|
|
|
|
await restore(clients, clients.id, id);
|
|
|
|
void applyEntityRestoredSuffix(portId, 'client', id, meta.userId).catch((err) => {
|
|
logger.warn({ err, clientId: id, portId }, 'Failed to clear archived suffix on client folder');
|
|
});
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'restore',
|
|
entityType: 'client',
|
|
entityId: id,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'client:restored', { clientId: id });
|
|
}
|
|
|
|
// ─── Contacts ─────────────────────────────────────────────────────────────────
|
|
|
|
export async function listContacts(clientId: string, portId: string) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
return db.query.clientContacts.findMany({
|
|
where: eq(clientContacts.clientId, clientId),
|
|
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
|
});
|
|
}
|
|
|
|
export async function addContact(
|
|
clientId: string,
|
|
portId: string,
|
|
data: {
|
|
channel: string;
|
|
value: string;
|
|
valueE164?: string | null;
|
|
valueCountry?: string | null;
|
|
label?: string;
|
|
isPrimary?: boolean;
|
|
notes?: string;
|
|
},
|
|
meta: AuditMeta,
|
|
) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
const [contact] = await db
|
|
.insert(clientContacts)
|
|
.values({ clientId, ...data })
|
|
.returning();
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'create',
|
|
entityType: 'clientContact',
|
|
entityId: contact!.id,
|
|
newValue: { clientId, channel: contact!.channel },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
|
|
|
return contact!;
|
|
}
|
|
|
|
export async function updateContact(
|
|
contactId: string,
|
|
clientId: string,
|
|
portId: string,
|
|
data: Partial<{
|
|
channel: string;
|
|
value: string;
|
|
valueE164: string | null;
|
|
valueCountry: string | null;
|
|
label: string;
|
|
isPrimary: boolean;
|
|
notes: string;
|
|
}>,
|
|
_meta: AuditMeta,
|
|
) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
const contact = await db.query.clientContacts.findFirst({
|
|
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
|
|
});
|
|
if (!contact) throw new NotFoundError('Contact');
|
|
|
|
const [updated] = await db
|
|
.update(clientContacts)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
// M-MT03: pin the WHERE to (id, clientId) for defense-in-depth.
|
|
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)))
|
|
.returning();
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
|
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Phase 3d - promote a non-primary client_contacts row to primary,
|
|
* demoting the prior primary for the same channel inside one
|
|
* transaction. Throws when the contact is already primary or the row
|
|
* does not exist on the targeted client.
|
|
*
|
|
* Used by the EOI dialog's "Set as default for future docs" toggle
|
|
* (via the eoi-overrides service) and by the client-detail "[EOI] Set
|
|
* as primary" action.
|
|
*/
|
|
export async function promoteContactToPrimary(
|
|
contactId: string,
|
|
clientId: string,
|
|
portId: string,
|
|
meta: AuditMeta,
|
|
) {
|
|
const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
const contact = await db.query.clientContacts.findFirst({
|
|
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
|
|
});
|
|
if (!contact) throw new NotFoundError('Contact');
|
|
if (contact.isPrimary) {
|
|
// No-op - return the row as-is so callers can be idempotent.
|
|
return contact;
|
|
}
|
|
|
|
const updated = await withTransaction(async (tx) => {
|
|
// Demote the prior primary for the same channel so the partial
|
|
// unique index doesn't reject the promotion.
|
|
await tx
|
|
.update(clientContacts)
|
|
.set({ isPrimary: false, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(clientContacts.clientId, clientId),
|
|
eq(clientContacts.channel, contact.channel),
|
|
eq(clientContacts.isPrimary, true),
|
|
),
|
|
);
|
|
const [row] = await tx
|
|
.update(clientContacts)
|
|
.set({ isPrimary: true, updatedAt: new Date() })
|
|
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)))
|
|
.returning();
|
|
return row!;
|
|
});
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'promote_to_primary',
|
|
entityType: 'client_contact',
|
|
entityId: contactId,
|
|
newValue: { clientId, channel: contact.channel, value: contact.value },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
|
|
|
return updated;
|
|
}
|
|
|
|
export async function removeContact(
|
|
contactId: string,
|
|
clientId: string,
|
|
portId: string,
|
|
_meta: AuditMeta,
|
|
) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
const contact = await db.query.clientContacts.findFirst({
|
|
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
|
|
});
|
|
if (!contact) throw new NotFoundError('Contact');
|
|
|
|
// M-MT03: pin (id, clientId) for defense-in-depth.
|
|
await db
|
|
.delete(clientContacts)
|
|
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)));
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
|
}
|
|
|
|
// ─── Addresses ────────────────────────────────────────────────────────────────
|
|
|
|
interface AddressInput {
|
|
label?: string;
|
|
streetAddress?: string | null;
|
|
city?: string | null;
|
|
subdivisionIso?: string | null;
|
|
postalCode?: string | null;
|
|
countryIso?: string | null;
|
|
isPrimary?: boolean;
|
|
}
|
|
|
|
export async function listClientAddresses(clientId: string, portId: string) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
return db.query.clientAddresses.findMany({
|
|
where: eq(clientAddresses.clientId, clientId),
|
|
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
|
});
|
|
}
|
|
|
|
export async function addClientAddress(
|
|
clientId: string,
|
|
portId: string,
|
|
data: AddressInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
// The unique partial index requires us to demote any existing primary
|
|
// before inserting a new one. We grab a row lock on the client to
|
|
// serialize concurrent primary-toggle requests against the same client -
|
|
// without this, two simultaneous "isPrimary=true" inserts can both
|
|
// observe "no existing primary" and one trips the unique index with a
|
|
// 5xx instead of being safely ordered.
|
|
const address = await withTransaction(async (tx) => {
|
|
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
|
|
|
|
const wantsPrimary = data.isPrimary ?? false;
|
|
if (wantsPrimary) {
|
|
await tx
|
|
.update(clientAddresses)
|
|
.set({ isPrimary: false })
|
|
.where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)));
|
|
}
|
|
const [row] = await tx
|
|
.insert(clientAddresses)
|
|
.values({
|
|
clientId,
|
|
portId,
|
|
label: data.label ?? 'Primary',
|
|
streetAddress: data.streetAddress ?? null,
|
|
city: data.city ?? null,
|
|
subdivisionIso: data.subdivisionIso ?? null,
|
|
postalCode: data.postalCode ?? null,
|
|
countryIso: data.countryIso ?? null,
|
|
isPrimary: wantsPrimary,
|
|
})
|
|
.returning();
|
|
return row!;
|
|
});
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'create',
|
|
entityType: 'clientAddress',
|
|
entityId: address.id,
|
|
newValue: { clientId, label: address.label, countryIso: address.countryIso },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
|
|
|
|
return address;
|
|
}
|
|
|
|
export async function updateClientAddress(
|
|
addressId: string,
|
|
clientId: string,
|
|
portId: string,
|
|
data: AddressInput,
|
|
_meta: AuditMeta,
|
|
) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
const existing = await db.query.clientAddresses.findFirst({
|
|
where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)),
|
|
});
|
|
if (!existing) throw new NotFoundError('Address');
|
|
|
|
const updated = await withTransaction(async (tx) => {
|
|
// Lock the client row to serialize primary-toggle changes - see addClientAddress.
|
|
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
|
|
|
|
if (data.isPrimary === true && !existing.isPrimary) {
|
|
await tx
|
|
.update(clientAddresses)
|
|
.set({ isPrimary: false })
|
|
.where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)));
|
|
}
|
|
const [row] = await tx
|
|
.update(clientAddresses)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(eq(clientAddresses.id, addressId))
|
|
.returning();
|
|
return row!;
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
|
|
|
|
return updated;
|
|
}
|
|
|
|
export async function removeClientAddress(
|
|
addressId: string,
|
|
clientId: string,
|
|
portId: string,
|
|
_meta: AuditMeta,
|
|
) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
const address = await db.query.clientAddresses.findFirst({
|
|
where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)),
|
|
});
|
|
if (!address) throw new NotFoundError('Address');
|
|
|
|
await db.delete(clientAddresses).where(eq(clientAddresses.id, addressId));
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
|
|
}
|
|
|
|
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function setClientTags(
|
|
clientId: string,
|
|
portId: string,
|
|
tagIds: string[],
|
|
meta: AuditMeta,
|
|
) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
await setEntityTags({
|
|
joinTable: clientTags,
|
|
entityColumn: clientTags.clientId,
|
|
tagColumn: clientTags.tagId,
|
|
entityId: clientId,
|
|
portId,
|
|
tagIds,
|
|
meta,
|
|
entityType: 'client',
|
|
});
|
|
}
|
|
|
|
// ─── Relationships ────────────────────────────────────────────────────────────
|
|
|
|
export async function listRelationships(clientId: string, portId: string) {
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
return db.query.clientRelationships.findMany({
|
|
where: (r, { and, or, eq }) =>
|
|
and(eq(r.portId, portId), or(eq(r.clientAId, clientId), eq(r.clientBId, clientId))),
|
|
});
|
|
}
|
|
|
|
export async function createRelationship(
|
|
clientId: string,
|
|
portId: string,
|
|
data: { clientBId: string; relationshipType: string; description?: string },
|
|
meta: AuditMeta,
|
|
) {
|
|
if (data.clientBId === clientId) {
|
|
throw new ValidationError('A client cannot have a relationship to themselves');
|
|
}
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
where: eq(clients.id, clientId),
|
|
});
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
// Tenant scope: clientBId arrives from the request body. Without this check
|
|
// a port-A caller could splice a port-B client UUID onto their own client's
|
|
// relationship row; the GET handler joins clientRelationships → clients with
|
|
// no port filter and would surface the foreign client's name + email.
|
|
const otherClient = await db.query.clients.findFirst({
|
|
where: and(eq(clients.id, data.clientBId), eq(clients.portId, portId)),
|
|
});
|
|
if (!otherClient) throw new ValidationError('clientBId not found in this port');
|
|
|
|
const [rel] = await db
|
|
.insert(clientRelationships)
|
|
.values({ portId, clientAId: clientId, ...data })
|
|
.returning();
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'create',
|
|
entityType: 'clientRelationship',
|
|
entityId: rel!.id,
|
|
newValue: { clientAId: clientId, clientBId: data.clientBId, type: data.relationshipType },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
return rel!;
|
|
}
|
|
|
|
export async function deleteRelationship(
|
|
relId: string,
|
|
clientId: string,
|
|
portId: string,
|
|
meta: AuditMeta,
|
|
) {
|
|
const rel = await db.query.clientRelationships.findFirst({
|
|
where: eq(clientRelationships.id, relId),
|
|
});
|
|
if (!rel || rel.portId !== portId) throw new NotFoundError('Relationship');
|
|
|
|
await db
|
|
.delete(clientRelationships)
|
|
.where(and(eq(clientRelationships.id, relId), eq(clientRelationships.portId, portId)));
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'delete',
|
|
entityType: 'clientRelationship',
|
|
entityId: relId,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
}
|
|
|
|
// ─── Find Duplicates ──────────────────────────────────────────────────────────
|
|
|
|
export async function findDuplicates(portId: string, fullName: string) {
|
|
return db.query.clients.findMany({
|
|
where: (c, { and, eq }) => and(eq(c.portId, portId), ilike(c.fullName, `%${fullName}%`)),
|
|
limit: 5,
|
|
});
|
|
}
|
|
|
|
// ─── Options (for comboboxes) ─────────────────────────────────────────────────
|
|
|
|
export async function listClientOptions(portId: string, search?: string) {
|
|
// Pickers only surface active rows. Archived clients are still resolvable
|
|
// by id (e.g. history views) but should not appear in dropdowns.
|
|
const conditions = [eq(clients.portId, portId), isNull(clients.archivedAt)];
|
|
if (search) {
|
|
conditions.push(ilike(clients.fullName, `%${search}%`));
|
|
}
|
|
|
|
return db
|
|
.select({ id: clients.id, fullName: clients.fullName })
|
|
.from(clients)
|
|
.where(and(...conditions))
|
|
.orderBy(clients.fullName)
|
|
.limit(50);
|
|
}
|