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:
2026-05-21 17:01:35 +02:00
parent abbaf406ab
commit 6cdb9af6b2
5 changed files with 170 additions and 48 deletions

View File

@@ -293,7 +293,27 @@ export class S3Backend implements StorageBackend {
async presignDownload(key: string, opts: PresignOpts): Promise<{ url: string; expiresAt: Date }> {
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) };
}