diff --git a/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx index 1e56405..9380821 100644 --- a/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx @@ -1,6 +1,4 @@ -import Link from 'next/link'; -import { Button } from '@/components/ui/button'; -import { PageHeader } from '@/components/shared/page-header'; +import { DocumentDetail } from '@/components/documents/document-detail'; interface PageProps { params: Promise<{ portSlug: string; id: string }>; @@ -8,17 +6,5 @@ interface PageProps { export default async function DocumentDetailPage({ params }: PageProps) { const { portSlug, id } = await params; - return ( -
- - Back to documents - - } - /> -
- ); + return ; } diff --git a/src/app/api/v1/documents/[id]/cancel/route.ts b/src/app/api/v1/documents/[id]/cancel/route.ts new file mode 100644 index 0000000..f4330e4 --- /dev/null +++ b/src/app/api/v1/documents/[id]/cancel/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { cancelDocument } from '@/lib/services/documents.service'; + +export const POST = withAuth( + withPermission('documents', 'edit', async (_req, ctx, params) => { + try { + const doc = await cancelDocument(params.id!, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: doc }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/documents/[id]/compose-completion-email/route.ts b/src/app/api/v1/documents/[id]/compose-completion-email/route.ts new file mode 100644 index 0000000..70af5a6 --- /dev/null +++ b/src/app/api/v1/documents/[id]/compose-completion-email/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { composeSignedDocEmail } from '@/lib/services/documents.service'; + +export const POST = withAuth( + withPermission('documents', 'view', async (_req, ctx, params) => { + try { + const draft = await composeSignedDocEmail(params.id!, ctx.portId); + return NextResponse.json({ data: draft }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/documents/[id]/route.ts b/src/app/api/v1/documents/[id]/route.ts index 5db5514..624b768 100644 --- a/src/app/api/v1/documents/[id]/route.ts +++ b/src/app/api/v1/documents/[id]/route.ts @@ -5,6 +5,7 @@ import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; import { getDocumentById, + getDocumentDetail, updateDocument, deleteDocument, } from '@/lib/services/documents.service'; @@ -13,6 +14,11 @@ import { updateDocumentSchema } from '@/lib/validators/documents'; export const GET = withAuth( withPermission('documents', 'view', async (req, ctx, params) => { try { + const url = new URL(req.url); + if (url.searchParams.get('detail') === 'true') { + const detail = await getDocumentDetail(params.id!, ctx.portId); + return NextResponse.json({ data: detail }); + } const doc = await getDocumentById(params.id!, ctx.portId); return NextResponse.json({ data: doc }); } catch (error) { diff --git a/src/app/api/v1/documents/[id]/watchers/[userId]/route.ts b/src/app/api/v1/documents/[id]/watchers/[userId]/route.ts new file mode 100644 index 0000000..66b3717 --- /dev/null +++ b/src/app/api/v1/documents/[id]/watchers/[userId]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { removeDocumentWatcher } from '@/lib/services/documents.service'; + +export const DELETE = withAuth( + withPermission('documents', 'edit', async (_req, ctx, params) => { + try { + await removeDocumentWatcher(params.id!, ctx.portId, params.userId!, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: { ok: true } }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/documents/[id]/watchers/route.ts b/src/app/api/v1/documents/[id]/watchers/route.ts new file mode 100644 index 0000000..d8a5411 --- /dev/null +++ b/src/app/api/v1/documents/[id]/watchers/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { addDocumentWatcher, listDocumentWatchers } from '@/lib/services/documents.service'; + +const addWatcherSchema = z.object({ + userId: z.string().min(1), +}); + +export const GET = withAuth( + withPermission('documents', 'view', async (_req, ctx, params) => { + try { + const watchers = await listDocumentWatchers(params.id!, ctx.portId); + return NextResponse.json({ data: watchers }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('documents', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, addWatcherSchema); + const watcher = await addDocumentWatcher(params.id!, ctx.portId, body.userId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: watcher }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/documents/document-detail.tsx b/src/components/documents/document-detail.tsx new file mode 100644 index 0000000..d1c3aca --- /dev/null +++ b/src/components/documents/document-detail.tsx @@ -0,0 +1,400 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import type { Route } from 'next'; +import { useRouter } from 'next/navigation'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { ArrowLeft, Bell, Download, Mail, Trash2, X } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { PageHeader } from '@/components/shared/page-header'; +import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; +import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { apiFetch } from '@/lib/api/client'; + +interface DetailDoc { + id: string; + title: string; + status: string; + documentType: string; + documensoId: string | null; + signedFileId: string | null; + reservationId: string | null; + interestId: string | null; + clientId: string | null; + yachtId: string | null; + companyId: string | null; + createdAt: string; + createdBy: string; +} + +interface DetailSigner { + id: string; + signerName: string; + signerEmail: string; + signerRole: string; + signingOrder: number; + status: string; + signedAt: string | null; + signingUrl: string | null; +} + +interface DetailEvent { + id: string; + eventType: string; + createdAt: string; + signerId: string | null; + eventData: Record | null; +} + +interface DetailWatcher { + userId: string; + addedBy: string; + addedAt: string; +} + +interface DetailResponse { + data: { + document: DetailDoc; + signers: DetailSigner[]; + events: DetailEvent[]; + watchers: DetailWatcher[]; + }; +} + +const STATUS_PILL_MAP: Record = { + draft: 'draft', + sent: 'sent', + partially_signed: 'partial', + completed: 'completed', + signed: 'signed', + expired: 'expired', + cancelled: 'cancelled', + rejected: 'rejected', + pending: 'pending', + declined: 'declined', +}; + +const SIGNER_PILL_MAP: Record = { + pending: 'pending', + signed: 'signed', + declined: 'declined', +}; + +interface DocumentDetailProps { + documentId: string; + portSlug: string; +} + +export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [isCancelling, setIsCancelling] = useState(false); + + const { data, isLoading, error } = useQuery({ + queryKey: ['document-detail', documentId], + queryFn: () => apiFetch(`/api/v1/documents/${documentId}?detail=true`), + }); + + useRealtimeInvalidation({ + 'document:updated': [['document-detail', documentId]], + 'document:sent': [['document-detail', documentId]], + 'document:completed': [['document-detail', documentId]], + 'document:cancelled': [['document-detail', documentId]], + 'document:rejected': [['document-detail', documentId]], + 'document:expired': [['document-detail', documentId]], + 'document:signer:signed': [['document-detail', documentId]], + 'document:signer:opened': [['document-detail', documentId]], + }); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (error || !data) { + return ( +
+ + + + Back to documents + + + } + /> +
+ ); + } + + const { document: doc, signers, events, watchers } = data.data; + + const handleRemind = async (signerId: string) => { + try { + await apiFetch(`/api/v1/documents/${documentId}/remind`, { + method: 'POST', + body: { signerId }, + }); + toast.success('Reminder sent'); + queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] }); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to send reminder'); + } + }; + + const handleCancel = async () => { + if (!confirm('Cancel this document? This voids it in Documenso and cannot be undone.')) return; + setIsCancelling(true); + try { + await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' }); + toast.success('Document cancelled'); + router.push(`/${portSlug}/documents`); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Cancel failed'); + setIsCancelling(false); + } + }; + + const handleEmailSignedPdf = async () => { + try { + const draft = await apiFetch<{ + data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> }; + }>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' }); + toast.info( + `Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} — opens in PR8 wizard`, + ); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to prepare email'); + } + }; + + const isInFlight = ['sent', 'partially_signed'].includes(doc.status); + const isComplete = ['completed', 'signed'].includes(doc.status); + + const subjectLink = doc.reservationId + ? { href: `/${portSlug}/berth-reservations/${doc.reservationId}`, label: 'Reservation' } + : doc.interestId + ? { href: `/${portSlug}/interests/${doc.interestId}`, label: 'Interest' } + : doc.clientId + ? { href: `/${portSlug}/clients/${doc.clientId}`, label: 'Client' } + : doc.yachtId + ? { href: `/${portSlug}/yachts/${doc.yachtId}`, label: 'Yacht' } + : doc.companyId + ? { href: `/${portSlug}/companies/${doc.companyId}`, label: 'Company' } + : null; + + return ( +
+ + + {doc.status.replace(/_/g, ' ')} + + + {signers.filter((s) => s.status === 'signed').length}/{signers.length} signed + + {watchers.length > 0 ? {watchers.length} watching : null} + + } + actions={ +
+ + {isComplete && doc.signedFileId ? ( + <> + + + + ) : null} + {isInFlight ? ( + + ) : null} +
+ } + variant="gradient" + /> + +
+ {/* Left column */} +
+
+

+ Signers +

+ {signers.length === 0 ? ( +

No signers attached.

+ ) : ( +
    + {signers.map((signer, idx) => ( +
  • +
    + {idx + 1} +
    +
    +
    +
    {signer.signerName}
    + + {signer.status} + +
    +
    + {signer.signerEmail} · {signer.signerRole} +
    +
    + {signer.signedAt + ? `Signed ${new Date(signer.signedAt).toLocaleDateString('en-GB')}` + : 'Pending'} +
    + {signer.status === 'pending' && doc.documensoId && isInFlight ? ( +
    + + {signer.signingUrl ? ( + + ) : null} +
    + ) : null} +
    +
  • + ))} +
+ )} +
+ + {subjectLink ? ( +
+

+ Linked entity +

+ + {subjectLink.label} → + +
+ ) : null} +
+ + {/* Right column */} +
+
+

+ Watchers +

+ {watchers.length === 0 ? ( +

No one is watching this document yet.

+ ) : ( +
    + {watchers.map((w) => ( +
  • + + {w.userId.slice(0, 8)}… + + +
  • + ))} +
+ )} +
+ +
+

+ Activity +

+ {events.length === 0 ? ( +

No events yet.

+ ) : ( +
    + {events.slice(0, 12).map((e) => ( +
  • +
    + {e.eventType.replace(/_/g, ' ')} +
    +
    + {new Date(e.createdAt).toLocaleString('en-GB')} +
    +
  • + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 43e2bb0..93d19af 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -1095,6 +1095,69 @@ export async function composeSignedDocEmail( }; } +// ─── Watchers (PR5) ─────────────────────────────────────────────────────────── + +export async function listDocumentWatchers(documentId: string, portId: string) { + await getDocumentById(documentId, portId); // port-scope check + return db + .select({ + userId: documentWatchers.userId, + addedBy: documentWatchers.addedBy, + addedAt: documentWatchers.addedAt, + }) + .from(documentWatchers) + .where(eq(documentWatchers.documentId, documentId)); +} + +export async function addDocumentWatcher( + documentId: string, + portId: string, + userId: string, + meta: AuditMeta, +): Promise<{ userId: string; addedAt: Date }> { + await getDocumentById(documentId, portId); + const [row] = await db + .insert(documentWatchers) + .values({ documentId, userId, addedBy: meta.userId }) + .onConflictDoNothing() + .returning(); + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'document_watcher', + entityId: documentId, + newValue: { documentId, watcherUserId: userId }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + emitToRoom(`port:${portId}`, 'document:updated', { documentId }); + return row ? { userId: row.userId, addedAt: row.addedAt } : { userId, addedAt: new Date() }; +} + +export async function removeDocumentWatcher( + documentId: string, + portId: string, + userId: string, + meta: AuditMeta, +): Promise { + await getDocumentById(documentId, portId); + await db + .delete(documentWatchers) + .where(and(eq(documentWatchers.documentId, documentId), eq(documentWatchers.userId, userId))); + void createAuditLog({ + userId: meta.userId, + portId, + action: 'delete', + entityType: 'document_watcher', + entityId: documentId, + oldValue: { documentId, watcherUserId: userId }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + emitToRoom(`port:${portId}`, 'document:updated', { documentId }); +} + /** * Skeleton for the create-document wizard entry point (PR6). *