feat(eoi): signed-EOI hero + send-signed-copy; fix search dropdown z-order
- EOI tab: when an EOI is already signed and none is in flight, lead with a SignedEoiCard (preview + download + send-to-client) instead of the big "Generate EOI" empty state; quiet "Generate new EOI" remains for re-issue - history rows + hero gain a "Send to client" action — POST /api/v1/documents/[id]/send-signed-copy emails the deal's client the finalized signed PDF (sendSignedCopyToClient reuses sendSigningCompleted), guarded by a confirm - topbar: header gets z-30 so the global search dropdown paints above page content (charts/tables were bleeding through — header + main are sibling normal-flow boxes, so the dropdown's own z-50 couldn't win cross-context). Stays below the z-50 modal tier. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
src/app/api/v1/documents/[id]/send-signed-copy/route.ts
Normal file
21
src/app/api/v1/documents/[id]/send-signed-copy/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { sendSignedCopyToClient } from '@/lib/services/documents.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually (re)send the finalized signed PDF to the deal's client. Backs
|
||||||
|
* the "Send signed copy to client" affordance on the EOI tab + document
|
||||||
|
* detail. Same `documents.edit` gate as the reminder endpoint.
|
||||||
|
*/
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('documents', 'edit', async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const result = await sendSignedCopyToClient(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
FileSignature,
|
FileSignature,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Mail,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Upload,
|
Upload,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -122,6 +123,32 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
// (which the storage backend serves with Content-Disposition=attachment,
|
// (which the storage backend serves with Content-Disposition=attachment,
|
||||||
// forcing a download even when the rep just wants to inspect the PDF).
|
// forcing a download even when the rep just wants to inspect the PDF).
|
||||||
const [previewFile, setPreviewFile] = useState<{ id: string; name?: string } | null>(null);
|
const [previewFile, setPreviewFile] = useState<{ id: string; name?: string } | null>(null);
|
||||||
|
const { confirm, dialog: confirmDialog } = useConfirmation();
|
||||||
|
|
||||||
|
// Manually (re)send the finalized signed PDF to the deal's client.
|
||||||
|
// Lifted to the parent (like the preview dialog) so every row + the
|
||||||
|
// signed-EOI hero share one confirm + handler. Guarded by a confirm so
|
||||||
|
// a stray click can't fire a real client email.
|
||||||
|
const handleSendCopy = useCallback(
|
||||||
|
async (documentId: string) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Send signed copy to client?',
|
||||||
|
description: 'Emails the deal’s client the finalized signed PDF as an attachment.',
|
||||||
|
confirmLabel: 'Send copy',
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ data: { recipientEmail: string } }>(
|
||||||
|
`/api/v1/documents/${documentId}/send-signed-copy`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
toast.success(`Signed copy sent to ${res.data.recipientEmail}.`);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err, 'Failed to send signed copy');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[confirm],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
|
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
|
||||||
queryKey: ['documents', { interestId, documentType: 'eoi' }],
|
queryKey: ['documents', { interestId, documentType: 'eoi' }],
|
||||||
@@ -134,6 +161,22 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
const docs = docsRes?.data ?? [];
|
const docs = docsRes?.data ?? [];
|
||||||
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
|
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
|
||||||
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
|
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
|
||||||
|
// Most-recent fully-signed EOI. When no EOI is in flight, this becomes
|
||||||
|
// the hero (instead of the generate/upload empty state) so a deal whose
|
||||||
|
// EOI is already done leads with the signed document, per UAT 2026-06-03.
|
||||||
|
const latestSignedDoc = useMemo(() => {
|
||||||
|
return (
|
||||||
|
docs
|
||||||
|
.filter((d) => d.status === 'completed')
|
||||||
|
.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))[0] ?? null
|
||||||
|
);
|
||||||
|
}, [docs]);
|
||||||
|
// History strip excludes whichever signed doc is shown as the hero so it
|
||||||
|
// isn't listed twice.
|
||||||
|
const historyDocs = useMemo(
|
||||||
|
() => completedDocs.filter((d) => d.id !== latestSignedDoc?.id),
|
||||||
|
[completedDocs, latestSignedDoc],
|
||||||
|
);
|
||||||
|
|
||||||
// Pulled at the parent so we can thread the active EOI's signers into the
|
// Pulled at the parent so we can thread the active EOI's signers into the
|
||||||
// ExternalEoiUploadDialog as a prefill seed. ActiveEoiCard hits the same
|
// ExternalEoiUploadDialog as a prefill seed. ActiveEoiCard hits the same
|
||||||
@@ -176,6 +219,15 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
portSlug={portSlug ?? null}
|
portSlug={portSlug ?? null}
|
||||||
onUploadSigned={() => setUploadSignedOpen(true)}
|
onUploadSigned={() => setUploadSignedOpen(true)}
|
||||||
onView={(id, name) => setPreviewFile({ id, name })}
|
onView={(id, name) => setPreviewFile({ id, name })}
|
||||||
|
onSendCopy={handleSendCopy}
|
||||||
|
/>
|
||||||
|
) : latestSignedDoc ? (
|
||||||
|
<SignedEoiCard
|
||||||
|
doc={latestSignedDoc}
|
||||||
|
portSlug={portSlug ?? null}
|
||||||
|
onView={(id, name) => setPreviewFile({ id, name })}
|
||||||
|
onSendCopy={handleSendCopy}
|
||||||
|
onGenerateNew={() => setGenerateOpen(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyEoiState
|
<EmptyEoiState
|
||||||
@@ -189,18 +241,18 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
{/* History strip - completed + cancelled EOIs from earlier in the
|
{/* History strip - completed + cancelled EOIs from earlier in the
|
||||||
deal's life. Quiet and skimmable; the active document above
|
deal's life. Quiet and skimmable; the active document above
|
||||||
carries the day-to-day attention. */}
|
carries the day-to-day attention. */}
|
||||||
{completedDocs.length > 0 && (
|
{historyDocs.length > 0 && (
|
||||||
<section className="rounded-lg border bg-background">
|
<section className="rounded-lg border bg-background">
|
||||||
<header className="flex items-center justify-between border-b px-4 py-2.5">
|
<header className="flex items-center justify-between border-b px-4 py-2.5">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
EOI history
|
EOI history
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
|
{historyDocs.length} {historyDocs.length === 1 ? 'document' : 'documents'}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<ul className="divide-y">
|
<ul className="divide-y">
|
||||||
{completedDocs.map((d) => (
|
{historyDocs.map((d) => (
|
||||||
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||||
<StatusBadge status={d.status} />
|
<StatusBadge status={d.status} />
|
||||||
<span className="flex-1 truncate font-medium">{d.title}</span>
|
<span className="flex-1 truncate font-medium">{d.title}</span>
|
||||||
@@ -210,8 +262,11 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
{d.signedFileId ? (
|
{d.signedFileId ? (
|
||||||
<SignedPdfActions
|
<SignedPdfActions
|
||||||
fileId={d.signedFileId}
|
fileId={d.signedFileId}
|
||||||
|
documentId={d.id}
|
||||||
|
isSignedCopySendable={d.status === 'completed'}
|
||||||
title={d.title}
|
title={d.title}
|
||||||
onView={(id, name) => setPreviewFile({ id, name })}
|
onView={(id, name) => setPreviewFile({ id, name })}
|
||||||
|
onSendCopy={handleSendCopy}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{portSlug && (
|
{portSlug && (
|
||||||
@@ -272,6 +327,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
fileId={previewFile?.id}
|
fileId={previewFile?.id}
|
||||||
fileName={previewFile?.name}
|
fileName={previewFile?.name}
|
||||||
/>
|
/>
|
||||||
|
{confirmDialog}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -283,11 +339,13 @@ function ActiveEoiCard({
|
|||||||
portSlug,
|
portSlug,
|
||||||
onUploadSigned,
|
onUploadSigned,
|
||||||
onView,
|
onView,
|
||||||
|
onSendCopy,
|
||||||
}: {
|
}: {
|
||||||
doc: DocumentRow;
|
doc: DocumentRow;
|
||||||
portSlug: string | null;
|
portSlug: string | null;
|
||||||
onUploadSigned: () => void;
|
onUploadSigned: () => void;
|
||||||
onView: (fileId: string, fileName?: string) => void;
|
onView: (fileId: string, fileName?: string) => void;
|
||||||
|
onSendCopy: (documentId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmation();
|
const { confirm, dialog: confirmDialog } = useConfirmation();
|
||||||
@@ -614,7 +672,14 @@ function ActiveEoiCard({
|
|||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Signed document
|
Signed document
|
||||||
</h3>
|
</h3>
|
||||||
<SignedPdfActions fileId={doc.signedFileId} title={doc.title} onView={onView} />
|
<SignedPdfActions
|
||||||
|
fileId={doc.signedFileId}
|
||||||
|
documentId={doc.id}
|
||||||
|
isSignedCopySendable={doc.status === 'completed'}
|
||||||
|
title={doc.title}
|
||||||
|
onView={onView}
|
||||||
|
onSendCopy={onSendCopy}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SignedPdfPreview fileId={doc.signedFileId} />
|
<SignedPdfPreview fileId={doc.signedFileId} />
|
||||||
</div>
|
</div>
|
||||||
@@ -711,6 +776,90 @@ function ActiveEoiCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Signed EOI hero (no active EOI, but one is already signed) ───────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the deal has a fully-signed EOI and nothing is in flight. Leads
|
||||||
|
* with the signed document (preview + download + send-to-client) instead of
|
||||||
|
* the generate/upload empty state — a deal whose EOI is done shouldn't open
|
||||||
|
* on a big "Generate EOI" CTA. A quiet "Generate new EOI" remains for the
|
||||||
|
* re-issue case.
|
||||||
|
*/
|
||||||
|
function SignedEoiCard({
|
||||||
|
doc,
|
||||||
|
portSlug,
|
||||||
|
onView,
|
||||||
|
onSendCopy,
|
||||||
|
onGenerateNew,
|
||||||
|
}: {
|
||||||
|
doc: DocumentRow;
|
||||||
|
portSlug: string | null;
|
||||||
|
onView: (fileId: string, fileName?: string) => void;
|
||||||
|
onSendCopy: (documentId: string) => void;
|
||||||
|
onGenerateNew: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
|
||||||
|
<header className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CheckCircle2 className="size-4 text-emerald-600" aria-hidden />
|
||||||
|
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
|
||||||
|
<StatusBadge status={doc.status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Signed · {new Date(doc.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||||
|
{doc.signedFileId ? (
|
||||||
|
<SignedPdfActions
|
||||||
|
fileId={doc.signedFileId}
|
||||||
|
documentId={doc.id}
|
||||||
|
isSignedCopySendable={doc.status === 'completed'}
|
||||||
|
title={doc.title}
|
||||||
|
onView={onView}
|
||||||
|
onSendCopy={onSendCopy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{portSlug && (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/documents/${doc.id}` as any}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open in Documents
|
||||||
|
<ExternalLink className="size-3" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{doc.signedFileId ? (
|
||||||
|
<div className="mt-4 rounded-lg border bg-background p-4">
|
||||||
|
<SignedPdfPreview fileId={doc.signedFileId} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 rounded-md border border-dashed bg-background p-3 text-xs text-muted-foreground">
|
||||||
|
The signed PDF isn't linked to this EOI yet, so inline preview, download, and send
|
||||||
|
aren't available. Open it in Documents — this lights up once migrated EOIs are
|
||||||
|
reconciled to their signed files.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="mt-3 flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This deal's EOI is signed. Generate a new one only if you need to re-issue it.
|
||||||
|
</p>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onGenerateNew} className="gap-1.5">
|
||||||
|
<FileSignature className="size-4" aria-hidden />
|
||||||
|
Generate new EOI
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline iframe preview of a signed PDF. Fetches a short-lived presigned
|
* Inline iframe preview of a signed PDF. Fetches a short-lived presigned
|
||||||
* URL from `/api/v1/files/[id]/download` and renders the browser's native
|
* URL from `/api/v1/files/[id]/download` and renders the browser's native
|
||||||
@@ -822,12 +971,21 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
|
|||||||
*/
|
*/
|
||||||
function SignedPdfActions({
|
function SignedPdfActions({
|
||||||
fileId,
|
fileId,
|
||||||
|
documentId,
|
||||||
|
isSignedCopySendable = false,
|
||||||
title,
|
title,
|
||||||
onView,
|
onView,
|
||||||
|
onSendCopy,
|
||||||
}: {
|
}: {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
|
/** Document id — required for the "Send to client" action (which targets
|
||||||
|
* the document, not the raw file). */
|
||||||
|
documentId?: string;
|
||||||
|
/** Only show "Send to client" for a fully-completed document. */
|
||||||
|
isSignedCopySendable?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
onView: (fileId: string, fileName?: string) => void;
|
onView: (fileId: string, fileName?: string) => void;
|
||||||
|
onSendCopy?: (documentId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -855,6 +1013,15 @@ function SignedPdfActions({
|
|||||||
>
|
>
|
||||||
<Download className="size-3" aria-hidden /> Download
|
<Download className="size-3" aria-hidden /> Download
|
||||||
</button>
|
</button>
|
||||||
|
{onSendCopy && documentId && isSignedCopySendable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSendCopy(documentId)}
|
||||||
|
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Mail className="size-3" aria-hidden /> Send to client
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,13 @@ export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
|
|||||||
// Sarah Doe"). Detail pages register their parent via
|
// Sarah Doe"). Detail pages register their parent via
|
||||||
// `useBreadcrumbHint` so the label is entity-aware; everything else
|
// `useBreadcrumbHint` so the label is entity-aware; everything else
|
||||||
// is URL-derived. See src/hooks/use-smart-back.ts.
|
// is URL-derived. See src/hooks/use-smart-back.ts.
|
||||||
<header className="relative grid h-14 grid-cols-[auto_1fr_auto] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
// `z-30` makes the header a stacking context that sits ABOVE the page
|
||||||
|
// content in <main>. Without it the search dropdown (absolute, z-50 within
|
||||||
|
// the header) still paints under main's charts/tables: header + main are
|
||||||
|
// sibling normal-flow boxes, so main paints later and wins regardless of
|
||||||
|
// the dropdown's own z. Kept below the modal/overlay tier (z-50) so
|
||||||
|
// dialogs still cover the bar.
|
||||||
|
<header className="relative z-30 grid h-14 grid-cols-[auto_1fr_auto] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||||
{/* LEFT: optional sidebar trigger (tablet) + smart back button.
|
{/* LEFT: optional sidebar trigger (tablet) + smart back button.
|
||||||
Hard-capped width so the column never extends into the
|
Hard-capped width so the column never extends into the
|
||||||
absolutely-positioned search bar's footprint. The cap is
|
absolutely-positioned search bar's footprint. The cap is
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
files,
|
files,
|
||||||
} from '@/lib/db/schema/documents';
|
} from '@/lib/db/schema/documents';
|
||||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||||
import { companies } from '@/lib/db/schema/companies';
|
import { companies } from '@/lib/db/schema/companies';
|
||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
@@ -1345,6 +1345,56 @@ async function sendCascadingInviteForNextSigner(doc: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually (re)send the finalized signed PDF of a completed document to the
|
||||||
|
* deal's client. Mirrors the automatic completion fan-out (sendSigningCompleted)
|
||||||
|
* but targets just the client recipient — backs the "Send signed copy to
|
||||||
|
* client" affordance on the EOI tab / document detail. Safe to call repeatedly.
|
||||||
|
*/
|
||||||
|
export async function sendSignedCopyToClient(
|
||||||
|
documentId: string,
|
||||||
|
portId: string,
|
||||||
|
): Promise<{ recipientEmail: string }> {
|
||||||
|
const doc = await getDocumentById(documentId, portId);
|
||||||
|
if (doc.status !== 'completed' || !doc.signedFileId) {
|
||||||
|
throw new ValidationError('This document has no signed PDF to send yet.');
|
||||||
|
}
|
||||||
|
const owner = await resolveDocumentOwner(portId, doc);
|
||||||
|
if (!owner || owner.entityType !== 'client') {
|
||||||
|
throw new ValidationError('No client is linked to this document to send the signed copy to.');
|
||||||
|
}
|
||||||
|
const client = await db.query.clients.findFirst({
|
||||||
|
where: eq(clients.id, owner.entityId),
|
||||||
|
columns: { fullName: true },
|
||||||
|
});
|
||||||
|
// Primary email via the same is_primary-desc, created_at-desc picker the
|
||||||
|
// clients list uses, scoped to the email channel.
|
||||||
|
const [emailRow] = await db
|
||||||
|
.select({ value: clientContacts.value })
|
||||||
|
.from(clientContacts)
|
||||||
|
.where(and(eq(clientContacts.clientId, owner.entityId), eq(clientContacts.channel, 'email')))
|
||||||
|
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
if (!emailRow?.value) {
|
||||||
|
throw new ValidationError('The linked client has no email address on file.');
|
||||||
|
}
|
||||||
|
const portRow = await db.query.ports.findFirst({
|
||||||
|
where: eq(ports.id, portId),
|
||||||
|
columns: { name: true },
|
||||||
|
});
|
||||||
|
await sendSigningCompleted({
|
||||||
|
portId,
|
||||||
|
portName: portRow?.name ?? 'Port Nimara',
|
||||||
|
recipients: [{ name: client?.fullName ?? '', email: emailRow.value }],
|
||||||
|
clientName: client?.fullName ?? doc.title,
|
||||||
|
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||||
|
completedAt: new Date(),
|
||||||
|
signedPdfFileId: doc.signedFileId,
|
||||||
|
signedPdfFilename: `signed-${doc.id}.pdf`,
|
||||||
|
});
|
||||||
|
return { recipientEmail: emailRow.value };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Owner-wins resolution ────────────────────────────────────────────────────
|
// ─── Owner-wins resolution ────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ResolvedOwner {
|
interface ResolvedOwner {
|
||||||
|
|||||||
Reference in New Issue
Block a user