Wave through the remaining audit-final-deferred items that aren't blocked
on the back-burnered Documenso work.
Multi-tenant isolation:
- Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim;
verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a
buggy issuer in some future code path that mixes port scopes — every
storage key generated by generateStorageKey() already prefixes the
slug. document-sends opts in for 24h emailed download links; other
callers continue working unchanged via the optional field.
DB schema reconciliation:
- Migration 0047 rebuilds system_settings unique index with NULLS NOT
DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are
uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate
(storage_backend, NULL) rows that had accumulated from race-prone
delete-then-insert patterns in ocr-config / settings / residential-
stages / ai-budget services. All four services converted to true
onConflictDoUpdate upserts so the race window is closed.
API uniformity:
- Response shape standardization: 16 routes converted from
`{ success: true }` to 204 No Content. CLAUDE.md documents the
convention (`{ data: <T> }` for content, 204 for empty mutations,
portal-auth retains `{ success: true }` for the frontend's auth chain).
- req.json() → parseBody() migration across 9 admin/CRM routes
(custom-fields, expenses/export ×3, currency convert,
search/recently-viewed, admin/duplicates, berths/pdf-{upload-url,
versions, parse-results}). Uniform 400 error shapes for
ZodError-flagged bodies.
Custom-fields merge tokens (shipped end-to-end):
- merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the
`{{custom.<fieldName>}}` shape.
- document-templates validator accepts the dynamic shape alongside
the static catalog tokens.
- document-sends.service mergeCustomFieldValues resolver fetches
per-port custom_field_definitions for client/interest/berth contexts
and substitutes stored values keyed by `{{custom.fieldName}}`.
- custom-fields-manager amber banner updated to reflect that merge
tokens now expand (search index + entity-diff remain documented
design limitations).
/api/v1/files cross-entity filtering:
- Validator + listFiles + uploadFile accept companyId AND yachtId
alongside clientId. file-upload-zone propagates both.
- New CompanyFilesTab component mirrors ClientFilesTab; restored as a
visible Documents tab in company-tabs.tsx (was a hidden stub).
Inline TODOs:
- Reviewed remaining two TODOs (per-user reminder schedule, import
worker handlers). Both are placeholders for future feature surfaces,
not bugs — per-port digest works for every customer; nothing
currently enqueues import jobs (verified). Annotated in BACKLOG.
BACKLOG.md updated to reflect what landed and what's still pending
(Documenso-related items still bundled with the back-burnered phases).
Tests: 1185/1185 vitest, tsc clean.
160 lines
4.9 KiB
TypeScript
160 lines
4.9 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { z } from 'zod';
|
|
import { and, eq, inArray } from 'drizzle-orm';
|
|
|
|
import type { AuthContext } from '@/lib/api/helpers';
|
|
import { parseBody } from '@/lib/api/route-helpers';
|
|
import { db } from '@/lib/db';
|
|
import { clients, clientMergeCandidates } from '@/lib/db/schema/clients';
|
|
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
|
import {
|
|
listPendingMergeCandidates,
|
|
mergeClients,
|
|
type MergeFieldChoices,
|
|
} from '@/lib/services/client-merge.service';
|
|
|
|
const confirmMergeSchema = z.object({
|
|
winnerId: z.string().min(1),
|
|
fieldChoices: z.record(z.string(), z.string()).optional(),
|
|
});
|
|
|
|
/**
|
|
* GET /api/v1/admin/duplicates
|
|
*
|
|
* Pending merge candidates for the current port, sorted by score.
|
|
* Each row hydrates its two client summaries so the review-queue UI
|
|
* can render side-by-side cards without an N+1 fetch.
|
|
*/
|
|
export async function listHandler(_req: Request, ctx: AuthContext): Promise<NextResponse> {
|
|
try {
|
|
const pairs = await listPendingMergeCandidates(ctx.portId);
|
|
if (pairs.length === 0) return NextResponse.json({ data: [] });
|
|
|
|
const ids = Array.from(new Set(pairs.flatMap((p) => [p.clientAId, p.clientBId])));
|
|
const clientRows = await db
|
|
.select({
|
|
id: clients.id,
|
|
fullName: clients.fullName,
|
|
archivedAt: clients.archivedAt,
|
|
mergedIntoClientId: clients.mergedIntoClientId,
|
|
createdAt: clients.createdAt,
|
|
})
|
|
.from(clients)
|
|
.where(inArray(clients.id, ids));
|
|
const clientById = new Map(clientRows.map((c) => [c.id, c]));
|
|
|
|
const data = pairs
|
|
.map((p) => {
|
|
const a = clientById.get(p.clientAId);
|
|
const b = clientById.get(p.clientBId);
|
|
if (!a || !b) return null; // FK orphan - shouldn't happen, but be defensive
|
|
// Skip pairs where one side has already been merged or archived.
|
|
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
|
|
return {
|
|
id: p.id,
|
|
score: p.score,
|
|
reasons: p.reasons,
|
|
createdAt: p.createdAt,
|
|
clientA: { id: a.id, fullName: a.fullName, createdAt: a.createdAt },
|
|
clientB: { id: b.id, fullName: b.fullName, createdAt: b.createdAt },
|
|
};
|
|
})
|
|
.filter((row): row is NonNullable<typeof row> => row !== null);
|
|
|
|
return NextResponse.json({ data });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/admin/duplicates/[id]/merge
|
|
*
|
|
* Body: { winnerId: string, fieldChoices?: MergeFieldChoices }
|
|
*
|
|
* Confirms a merge candidate. The winner is the one the user picked
|
|
* to keep; the other side becomes the loser. Calls into the merge
|
|
* service which is the only path that touches client_merge_log.
|
|
*/
|
|
export async function confirmMergeHandler(
|
|
req: NextRequest,
|
|
ctx: AuthContext,
|
|
params: { id?: string },
|
|
): Promise<NextResponse> {
|
|
try {
|
|
const id = params.id ?? '';
|
|
const body = await parseBody(req, confirmMergeSchema);
|
|
|
|
const [candidate] = await db
|
|
.select()
|
|
.from(clientMergeCandidates)
|
|
.where(
|
|
and(
|
|
eq(clientMergeCandidates.id, id),
|
|
eq(clientMergeCandidates.portId, ctx.portId),
|
|
eq(clientMergeCandidates.status, 'pending'),
|
|
),
|
|
);
|
|
if (!candidate) throw new NotFoundError('Merge candidate');
|
|
|
|
const loserId =
|
|
body.winnerId === candidate.clientAId
|
|
? candidate.clientBId
|
|
: body.winnerId === candidate.clientBId
|
|
? candidate.clientAId
|
|
: null;
|
|
if (!loserId) {
|
|
throw new ValidationError('winnerId must match one of the candidate clients');
|
|
}
|
|
|
|
const result = await mergeClients({
|
|
winnerId: body.winnerId,
|
|
loserId,
|
|
mergedBy: ctx.userId,
|
|
callerPortId: ctx.portId,
|
|
fieldChoices: body.fieldChoices as MergeFieldChoices | undefined,
|
|
});
|
|
|
|
return NextResponse.json({ data: result });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/admin/duplicates/[id]/dismiss
|
|
*
|
|
* Mark a merge candidate as dismissed. The background scoring job
|
|
* skips dismissed pairs on subsequent runs (a future score increase
|
|
* can re-create them).
|
|
*/
|
|
export async function dismissHandler(
|
|
_req: Request,
|
|
ctx: AuthContext,
|
|
params: { id?: string },
|
|
): Promise<NextResponse> {
|
|
try {
|
|
const id = params.id ?? '';
|
|
const result = await db
|
|
.update(clientMergeCandidates)
|
|
.set({
|
|
status: 'dismissed',
|
|
resolvedAt: new Date(),
|
|
resolvedBy: ctx.userId,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(clientMergeCandidates.id, id),
|
|
eq(clientMergeCandidates.portId, ctx.portId),
|
|
eq(clientMergeCandidates.status, 'pending'),
|
|
),
|
|
)
|
|
.returning({ id: clientMergeCandidates.id });
|
|
|
|
if (result.length === 0) throw new NotFoundError('Merge candidate');
|
|
return NextResponse.json({ data: { id: result[0]!.id, status: 'dismissed' } });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|