fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override
Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Loader2, Upload } from 'lucide-react';
|
import { Loader2, Upload } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -9,7 +9,9 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { formatBerthRange } from '@/lib/templates/berth-range';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,12 +36,49 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
const [signerNames, setSignerNames] = useState('');
|
const [signerNames, setSignerNames] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
const mutation = useMutation({
|
// Fetched on open to power the default title:
|
||||||
|
// "External EOI — <Client> — <berth range> — YYYY-MM-DD". Without
|
||||||
|
// this the file lands as just "External EOI - <date>" which is
|
||||||
|
// unscannable in any list when a port has multiple deals closing on
|
||||||
|
// the same day.
|
||||||
|
const { data: interestData } = useQuery<{ data: { clientName: string | null } }>({
|
||||||
|
queryKey: ['interests', interestId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: { clientName: string | null } }>(`/api/v1/interests/${interestId}`),
|
||||||
|
enabled: open,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const { data: berthsData } = useQuery<{ data: Array<{ mooringNumber: string | null }> }>({
|
||||||
|
queryKey: ['interests', interestId, 'berths'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: Array<{ mooringNumber: string | null }> }>(
|
||||||
|
`/api/v1/interests/${interestId}/berths`,
|
||||||
|
),
|
||||||
|
enabled: open,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultTitle = useMemo(() => {
|
||||||
|
const date = signedAt || new Date().toISOString().slice(0, 10);
|
||||||
|
const moorings = (berthsData?.data ?? [])
|
||||||
|
.map((b) => b.mooringNumber)
|
||||||
|
.filter((m): m is string => !!m);
|
||||||
|
const berthLabel = moorings.length > 0 ? formatBerthRange(moorings) : null;
|
||||||
|
const clientName = interestData?.data?.clientName ?? null;
|
||||||
|
const parts = ['External EOI'];
|
||||||
|
if (clientName) parts.push(clientName);
|
||||||
|
if (berthLabel) parts.push(berthLabel);
|
||||||
|
parts.push(date);
|
||||||
|
return parts.join(' — ');
|
||||||
|
}, [interestData, berthsData, signedAt]);
|
||||||
|
|
||||||
|
const mutation = useMutation<{ data?: { stageChanged?: boolean } }, Error, void>({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!file) throw new Error('No file selected');
|
if (!file) throw new Error('No file selected');
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
if (title) form.append('title', title);
|
const effectiveTitle = title.trim() || defaultTitle;
|
||||||
|
if (effectiveTitle) form.append('title', effectiveTitle);
|
||||||
if (signedAt) form.append('signedAt', signedAt);
|
if (signedAt) form.append('signedAt', signedAt);
|
||||||
if (signerNames) form.append('signerNames', signerNames);
|
if (signerNames) form.append('signerNames', signerNames);
|
||||||
if (notes) form.append('notes', notes);
|
if (notes) form.append('notes', notes);
|
||||||
@@ -54,10 +93,15 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
};
|
};
|
||||||
throw new Error(err.error ?? 'Upload failed');
|
throw new Error(err.error ?? 'Upload failed');
|
||||||
}
|
}
|
||||||
return res.json();
|
return (await res.json()) as { data?: { stageChanged?: boolean } };
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (response) => {
|
||||||
toast.success('External EOI uploaded — interest advanced to EOI Signed');
|
const stageChanged = response?.data?.stageChanged === true;
|
||||||
|
toast.success(
|
||||||
|
stageChanged
|
||||||
|
? 'External EOI uploaded. Stage advanced to EOI Signed.'
|
||||||
|
: 'External EOI uploaded. Filed against this deal (stage unchanged).',
|
||||||
|
);
|
||||||
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||||
qc.invalidateQueries({ queryKey: ['interests'] });
|
qc.invalidateQueries({ queryKey: ['interests'] });
|
||||||
qc.invalidateQueries({ queryKey: ['documents'] });
|
qc.invalidateQueries({ queryKey: ['documents'] });
|
||||||
@@ -100,9 +144,12 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Defaults to 'External EOI - <date>'"
|
placeholder={defaultTitle}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Leave blank to use the default shown above.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Date signed</Label>
|
<Label>Date signed</Label>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { EoiCancelDialog } from '@/components/documents/eoi-cancel-dialog';
|
|||||||
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
|
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
|
||||||
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
|
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
|
||||||
import { SigningProgress } from '@/components/documents/signing-progress';
|
import { SigningProgress } from '@/components/documents/signing-progress';
|
||||||
|
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||||
@@ -103,6 +104,11 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
const [generateOpen, setGenerateOpen] = useState(false);
|
const [generateOpen, setGenerateOpen] = useState(false);
|
||||||
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
|
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
|
||||||
|
// Lifted preview state so the View button on every signed-PDF row opens
|
||||||
|
// the in-app preview dialog rather than navigating to a presigned URL
|
||||||
|
// (which the storage backend serves with Content-Disposition=attachment,
|
||||||
|
// forcing a download even when the rep just wants to inspect the PDF).
|
||||||
|
const [previewFile, setPreviewFile] = useState<{ id: string; name?: string } | null>(null);
|
||||||
|
|
||||||
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' }],
|
||||||
@@ -125,6 +131,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
doc={activeDoc}
|
doc={activeDoc}
|
||||||
portSlug={portSlug ?? null}
|
portSlug={portSlug ?? null}
|
||||||
onUploadSigned={() => setUploadSignedOpen(true)}
|
onUploadSigned={() => setUploadSignedOpen(true)}
|
||||||
|
onView={(id, name) => setPreviewFile({ id, name })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyEoiState
|
<EmptyEoiState
|
||||||
@@ -154,7 +161,13 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{new Date(d.createdAt).toLocaleDateString()}
|
{new Date(d.createdAt).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
{d.signedFileId ? <SignedPdfActions fileId={d.signedFileId} /> : null}
|
{d.signedFileId ? (
|
||||||
|
<SignedPdfActions
|
||||||
|
fileId={d.signedFileId}
|
||||||
|
title={d.title}
|
||||||
|
onView={(id, name) => setPreviewFile({ id, name })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{portSlug && (
|
{portSlug && (
|
||||||
<Link
|
<Link
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -183,6 +196,15 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
onOpenChange={setUploadSignedOpen}
|
onOpenChange={setUploadSignedOpen}
|
||||||
interestId={interestId}
|
interestId={interestId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FilePreviewDialog
|
||||||
|
open={!!previewFile}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setPreviewFile(null);
|
||||||
|
}}
|
||||||
|
fileId={previewFile?.id}
|
||||||
|
fileName={previewFile?.name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -193,10 +215,12 @@ function ActiveEoiCard({
|
|||||||
doc,
|
doc,
|
||||||
portSlug,
|
portSlug,
|
||||||
onUploadSigned,
|
onUploadSigned,
|
||||||
|
onView,
|
||||||
}: {
|
}: {
|
||||||
doc: DocumentRow;
|
doc: DocumentRow;
|
||||||
portSlug: string | null;
|
portSlug: string | null;
|
||||||
onUploadSigned: () => void;
|
onUploadSigned: () => void;
|
||||||
|
onView: (fileId: string, fileName?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmation();
|
const { confirm, dialog: confirmDialog } = useConfirmation();
|
||||||
@@ -399,7 +423,7 @@ 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} />
|
<SignedPdfActions fileId={doc.signedFileId} title={doc.title} onView={onView} />
|
||||||
</div>
|
</div>
|
||||||
<SignedPdfPreview fileId={doc.signedFileId} />
|
<SignedPdfPreview fileId={doc.signedFileId} />
|
||||||
</div>
|
</div>
|
||||||
@@ -581,22 +605,26 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View + Download buttons for a signed PDF. `/api/v1/files/[id]/download`
|
* View + Download buttons for a signed PDF. View opens the in-app
|
||||||
* returns a presigned URL in JSON (rather than streaming the file), so
|
* preview dialog (lifted to the parent so a single dialog instance
|
||||||
* we fetch the URL via `apiFetch` and then either open it in a new tab
|
* serves every row); Download fetches the presigned URL and triggers a
|
||||||
* (View) or trigger a programmatic download (Download).
|
* filename-preserving download via the shared helper.
|
||||||
*/
|
*/
|
||||||
function SignedPdfActions({ fileId }: { fileId: string }) {
|
function SignedPdfActions({
|
||||||
const open = async (mode: 'view' | 'download') => {
|
fileId,
|
||||||
|
title,
|
||||||
|
onView,
|
||||||
|
}: {
|
||||||
|
fileId: string;
|
||||||
|
title?: string;
|
||||||
|
onView: (fileId: string, fileName?: string) => void;
|
||||||
|
}) {
|
||||||
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
||||||
`/api/v1/files/${fileId}/download`,
|
`/api/v1/files/${fileId}/download`,
|
||||||
);
|
);
|
||||||
if (mode === 'view') {
|
triggerUrlDownload(res.data.url, res.data.filename);
|
||||||
window.open(res.data.url, '_blank', 'noopener,noreferrer');
|
|
||||||
} else {
|
|
||||||
triggerUrlDownload(res.data.url, res.data.filename);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err, 'Failed to fetch signed PDF');
|
toastError(err, 'Failed to fetch signed PDF');
|
||||||
}
|
}
|
||||||
@@ -605,14 +633,14 @@ function SignedPdfActions({ fileId }: { fileId: string }) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => open('view')}
|
onClick={() => onView(fileId, title)}
|
||||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Eye className="size-3" aria-hidden /> View
|
<Eye className="size-3" aria-hidden /> View
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => open('download')}
|
onClick={handleDownload}
|
||||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Download className="size-3" aria-hidden /> Download
|
<Download className="size-3" aria-hidden /> Download
|
||||||
|
|||||||
@@ -135,34 +135,43 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advance the interest stage to eoi_signed (no-op if already past it).
|
// Two concerns to keep separate:
|
||||||
// We bypass canTransitionStage explicitly because the operator just
|
// 1. Document metadata — always write `dateEoiSigned` + `eoiStatus`
|
||||||
// brought concrete proof that the EOI is signed — that's higher
|
// from the upload. Even if the rep already advanced the stage
|
||||||
// confidence than a normal forward-jump.
|
// manually, the paper signing event needs a recorded date so
|
||||||
if (
|
// downstream surfaces (SkipAheadBanner, milestone strip, EOI
|
||||||
|
// merge fields) reflect reality. Honour an existing
|
||||||
|
// dateEoiSigned (don't overwrite if already set — covers the
|
||||||
|
// case where the rep is uploading evidence for an event whose
|
||||||
|
// date was already backfilled).
|
||||||
|
// 2. Stage advance — only when the deal hasn't reached eoi_signed
|
||||||
|
// yet. Bypasses canTransitionStage because the operator just
|
||||||
|
// brought concrete proof.
|
||||||
|
const shouldAdvanceStage =
|
||||||
interest.pipelineStage === 'open' ||
|
interest.pipelineStage === 'open' ||
|
||||||
interest.pipelineStage === 'details_sent' ||
|
interest.pipelineStage === 'details_sent' ||
|
||||||
interest.pipelineStage === 'in_communication' ||
|
interest.pipelineStage === 'in_communication' ||
|
||||||
interest.pipelineStage === 'eoi_sent'
|
interest.pipelineStage === 'eoi_sent';
|
||||||
) {
|
|
||||||
await tx
|
|
||||||
.update(interests)
|
|
||||||
.set({
|
|
||||||
pipelineStage: 'eoi_signed',
|
|
||||||
eoiStatus: 'signed',
|
|
||||||
dateEoiSigned: input.signedAt ?? new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(interests.id, interestId));
|
|
||||||
} else {
|
|
||||||
// Past eoi_signed — just record the document, don't touch stage.
|
|
||||||
await tx.update(interests).set({ updatedAt: new Date() }).where(eq(interests.id, interestId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { documentId: doc.id, fileId: fileRecord.id };
|
await tx
|
||||||
|
.update(interests)
|
||||||
|
.set({
|
||||||
|
dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(),
|
||||||
|
eoiStatus: 'signed',
|
||||||
|
...(shouldAdvanceStage ? { pipelineStage: 'eoi_signed' as const } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(interests.id, interestId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentId: doc.id,
|
||||||
|
fileId: fileRecord.id,
|
||||||
|
stageChanged: shouldAdvanceStage,
|
||||||
|
newStage: shouldAdvanceStage ? ('eoi_signed' as const) : interest.pipelineStage,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { documentId: docId, fileId: fId } = result;
|
const { documentId: docId, fileId: fId, stageChanged, newStage } = result;
|
||||||
|
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
portId,
|
portId,
|
||||||
@@ -184,5 +193,19 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
|||||||
|
|
||||||
emitToRoom(`port:${portId}`, 'document:completed', { documentId: docId });
|
emitToRoom(`port:${portId}`, 'document:completed', { documentId: docId });
|
||||||
|
|
||||||
return { documentId: docId, fileId: fId };
|
// Berth rules engine: a manually-uploaded external EOI is still an
|
||||||
|
// EOI-signed event for the rules that watch this trigger (e.g.
|
||||||
|
// auto-mark the primary berth Under Offer). Fire via dynamic import
|
||||||
|
// to dodge the circular dep between berth-rules-engine and the
|
||||||
|
// interest services.
|
||||||
|
try {
|
||||||
|
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
|
||||||
|
await evaluateRule('eoi_signed', interestId, portId, meta);
|
||||||
|
} catch {
|
||||||
|
// Swallow — rules engine failures should never block the upload
|
||||||
|
// that the rep has already completed end-to-end. The orphan-reaper
|
||||||
|
// path doesn't apply; a missed rule evaluation is a soft failure.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { documentId: docId, fileId: fId, stageChanged, newStage };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,11 @@ export async function uploadFile(
|
|||||||
|
|
||||||
export async function getDownloadUrl(id: string, portId: string) {
|
export async function getDownloadUrl(id: string, portId: string) {
|
||||||
const file = await getFileById(id, portId);
|
const file = await getFileById(id, portId);
|
||||||
const url = await presignDownloadUrl(file.storagePath);
|
// Pass the canonical filename through to the presign so MinIO/S3
|
||||||
|
// returns Content-Disposition with the original name. Without the
|
||||||
|
// override the file lands with the bare storage-key UUID (no
|
||||||
|
// extension) in every browser.
|
||||||
|
const url = await presignDownloadUrl(file.storagePath, 900, file.filename);
|
||||||
return { url, filename: file.filename };
|
return { url, filename: file.filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,27 @@ export class S3Backend implements StorageBackend {
|
|||||||
|
|
||||||
async presignDownload(key: string, opts: PresignOpts): Promise<{ url: string; expiresAt: Date }> {
|
async presignDownload(key: string, opts: PresignOpts): Promise<{ url: string; expiresAt: Date }> {
|
||||||
const expiry = opts.expirySeconds ?? 900;
|
const expiry = opts.expirySeconds ?? 900;
|
||||||
const url = await this.client.presignedGetObject(this.bucket, key, expiry);
|
// Pass response-header overrides to minio-js's reqParams so the
|
||||||
|
// browser sees the original filename / content-type instead of the
|
||||||
|
// storage-key UUID. Without this every signed download lands with
|
||||||
|
// a bare UUID and no extension. Filenames are escaped per RFC 5987
|
||||||
|
// so a name like "Étude.pdf" survives the round-trip.
|
||||||
|
const reqParams: Record<string, string> = {};
|
||||||
|
if (opts.filename) {
|
||||||
|
const ascii = opts.filename.replace(/[^\x20-\x7e]/g, '_');
|
||||||
|
const encoded = encodeURIComponent(opts.filename);
|
||||||
|
reqParams['response-content-disposition'] =
|
||||||
|
`attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
||||||
|
}
|
||||||
|
if (opts.contentType) {
|
||||||
|
reqParams['response-content-type'] = opts.contentType;
|
||||||
|
}
|
||||||
|
const url = await this.client.presignedGetObject(
|
||||||
|
this.bucket,
|
||||||
|
key,
|
||||||
|
expiry,
|
||||||
|
Object.keys(reqParams).length > 0 ? reqParams : undefined,
|
||||||
|
);
|
||||||
return { url, expiresAt: new Date(Date.now() + expiry * 1000) };
|
return { url, expiresAt: new Date(Date.now() + expiry * 1000) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user