diff --git a/src/app/api/v1/interests/[id]/field-history/route.ts b/src/app/api/v1/interests/[id]/field-history/route.ts new file mode 100644 index 00000000..5162a316 --- /dev/null +++ b/src/app/api/v1/interests/[id]/field-history/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { and, desc, eq } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { interestFieldHistory } from '@/lib/db/schema'; +import { errorResponse } from '@/lib/errors'; + +/** + * GET /api/v1/interests/[id]/field-history + * + * Returns the field-level override log for the interest, newest first. + * Powers the "Field history" panel on Interest detail (and the matching + * panel on Client detail via /clients/[id]/field-history). + */ +export const GET = withAuth( + withPermission('interests', 'view', async (_req, ctx, params) => { + try { + const rows = await db + .select() + .from(interestFieldHistory) + .where( + and( + eq(interestFieldHistory.portId, ctx.portId), + eq(interestFieldHistory.interestId, params.id!), + ), + ) + .orderBy(desc(interestFieldHistory.createdAt)) + .limit(100); + return NextResponse.json({ data: rows }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/files/file-preview-dialog.tsx b/src/components/files/file-preview-dialog.tsx index f45ea719..99fa2190 100644 --- a/src/components/files/file-preview-dialog.tsx +++ b/src/components/files/file-preview-dialog.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; -import { ExternalLink, ZoomIn } from 'lucide-react'; +import { Download, ExternalLink, FileWarning, ZoomIn } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { @@ -12,6 +12,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api/client'; // yet-another-react-lightbox is ~50kb, lazy-load it. @@ -37,6 +38,50 @@ interface FilePreviewDialogProps { mimeType?: string; } +/** + * Routes a file's mime type to one of seven preview surfaces. Order + * matters — `application/pdf` is matched before the generic + * "application/*" bucket so PDFs stay on the rich pdfjs viewer. + */ +type PreviewKind = + | 'image' + | 'pdf' + | 'text' + | 'audio' + | 'video' + | 'office' // .docx / .xlsx / .pptx / .odt / .ods + | 'unknown'; + +function previewKindFor(mimeType: string | undefined, fileName: string | undefined): PreviewKind { + const mt = mimeType ?? ''; + const name = (fileName ?? '').toLowerCase(); + if (mt.startsWith('image/')) return 'image'; + if (mt === 'application/pdf' || name.endsWith('.pdf')) return 'pdf'; + if (mt.startsWith('audio/') || /\.(mp3|wav|m4a|ogg|flac)$/.test(name)) return 'audio'; + if (mt.startsWith('video/') || /\.(mp4|mov|webm|m4v|ogg)$/.test(name)) return 'video'; + if ( + mt.startsWith('text/') || + mt === 'application/json' || + mt === 'application/xml' || + /\.(txt|md|csv|tsv|json|xml|log|yaml|yml|conf|ini|html?)$/.test(name) + ) { + return 'text'; + } + if ( + mt.includes('officedocument') || + mt === 'application/msword' || + mt === 'application/vnd.ms-excel' || + mt === 'application/vnd.ms-powerpoint' || + mt === 'application/vnd.oasis.opendocument.text' || + mt === 'application/vnd.oasis.opendocument.spreadsheet' || + mt === 'application/vnd.oasis.opendocument.presentation' || + /\.(docx?|xlsx?|pptx?|odt|ods|odp)$/.test(name) + ) { + return 'office'; + } + return 'unknown'; +} + export function FilePreviewDialog({ open, onOpenChange, @@ -57,8 +102,7 @@ export function FilePreviewDialog({ const loading = previewQuery.isLoading; const error = previewQuery.error ? 'Failed to load preview' : null; - const isImage = mimeType?.startsWith('image/'); - const isPdf = mimeType === 'application/pdf'; + const kind = previewKindFor(mimeType, fileName); return ( @@ -98,7 +142,7 @@ export function FilePreviewDialog({ )} - {!loading && !error && previewUrl && isImage && ( + {!loading && !error && previewUrl && kind === 'image' && ( )} - {!loading && !error && previewUrl && isPdf && ( + {!loading && !error && previewUrl && kind === 'pdf' && ( )} + + {!loading && !error && previewUrl && kind === 'text' && } + + {!loading && !error && previewUrl && kind === 'audio' && ( +
+ {/* HTML5
+ )} + + {!loading && !error && previewUrl && kind === 'video' && ( +
+
+ )} + + {!loading && !error && previewUrl && kind === 'office' && ( + // Office documents render via Microsoft's hosted Office viewer + // — public URL only; presigned download URLs include a token + // in the query string so they work here even though the file + // isn't world-public. The viewer streams the document and + // renders a high-fidelity preview without us shipping a + // headless LibreOffice. Falls back to "download to view" if + // the embed loads but renders nothing (e.g. CORS rejected) — + // detection is hard so we just keep the download CTA below. +