fix(audit-final): pre-merge hardening + expense receipt UI
Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Camera, Loader2, ScanLine, Upload } from 'lucide-react';
|
||||
import { Camera, Loader2, ScanLine, Upload, X } from 'lucide-react';
|
||||
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
|
||||
@@ -30,6 +30,11 @@ interface ScanResult {
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface UploadedFileMeta {
|
||||
id: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export default function ScanReceiptPage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const router = useRouter();
|
||||
@@ -38,6 +43,13 @@ export default function ScanReceiptPage() {
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
// After OCR succeeds we also upload the receipt to /api/v1/files/upload
|
||||
// so the expense links to the actual image. The legacy scanner skipped
|
||||
// this step and saved expenses without their receipt — which silently
|
||||
// disqualified them from parent-company reimbursement (the warning the
|
||||
// PDF export now surfaces).
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedFileMeta | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
|
||||
const { setChrome } = useMobileChrome();
|
||||
useEffect(() => {
|
||||
@@ -74,6 +86,29 @@ export default function ScanReceiptPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Uploads the receipt image to /api/v1/files/upload (category=receipt)
|
||||
// so the new expense row can link to it via receiptFileIds. Runs in
|
||||
// parallel with the OCR scan so the rep can keep editing fields while
|
||||
// the upload completes.
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File): Promise<UploadedFileMeta> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', 'receipt');
|
||||
const res = await fetch('/api/v1/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Receipt upload failed');
|
||||
const json = (await res.json()) as { data: { id: string; filename: string } };
|
||||
return { id: json.data.id, filename: json.data.filename };
|
||||
},
|
||||
onSuccess: (meta) => {
|
||||
setUploadedFile(meta);
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/expenses', {
|
||||
@@ -85,6 +120,9 @@ export default function ScanReceiptPage() {
|
||||
category: category || undefined,
|
||||
expenseDate: date ? new Date(date) : new Date(),
|
||||
paymentStatus: 'unpaid',
|
||||
receiptFileIds: uploadedFile ? [uploadedFile.id] : undefined,
|
||||
// The scanner path always has a receipt (we wouldn't have OCR'd
|
||||
// it otherwise), so we never need the no-receipt flag here.
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -95,12 +133,32 @@ export default function ScanReceiptPage() {
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setPendingFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
// Kick off OCR scan + storage upload concurrently. The two are
|
||||
// independent server calls and the rep is staring at the preview
|
||||
// while both run.
|
||||
scanMutation.mutate(file);
|
||||
uploadMutation.mutate(file);
|
||||
}
|
||||
|
||||
function handleClearReceipt() {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl(null);
|
||||
setUploadedFile(null);
|
||||
setPendingFile(null);
|
||||
setScanResult(null);
|
||||
// Reset in-flight mutations so a late onSuccess doesn't repopulate
|
||||
// the form against an already-cleared UI (audit finding: stale
|
||||
// receipt could land on the next Save).
|
||||
scanMutation.reset();
|
||||
uploadMutation.reset();
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
if (cameraInputRef.current) cameraInputRef.current.value = '';
|
||||
}
|
||||
void pendingFile;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="hidden sm:block">
|
||||
@@ -119,18 +177,45 @@ export default function ScanReceiptPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{previewUrl ? (
|
||||
<div
|
||||
className="border-2 border-dashed rounded-lg p-4 text-center cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Receipt preview"
|
||||
className="max-h-64 mx-auto rounded object-contain"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="relative border-2 border-dashed rounded-lg p-4 text-center bg-muted/20">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Receipt preview"
|
||||
className="max-h-64 mx-auto rounded object-contain"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearReceipt}
|
||||
aria-label="Remove receipt"
|
||||
className="absolute top-2 right-2 rounded-full bg-background/80 hover:bg-background border p-1.5 shadow-sm"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{uploadMutation.isPending && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Uploading receipt…
|
||||
</span>
|
||||
)}
|
||||
{uploadedFile && (
|
||||
<span className="text-emerald-600">
|
||||
Receipt uploaded ({uploadedFile.filename})
|
||||
</span>
|
||||
)}
|
||||
{uploadMutation.isError && (
|
||||
<span className="text-destructive">
|
||||
Receipt upload failed — save will still create the expense without an image.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{/* Camera button — available on mobile devices that surface the
|
||||
built-in capture flow when an `image/*` input has the
|
||||
`capture` attribute. Hidden on desktop where it's a no-op. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
@@ -140,6 +225,8 @@ export default function ScanReceiptPage() {
|
||||
<Camera className="mr-2 h-5 w-5" />
|
||||
Take photo
|
||||
</Button>
|
||||
{/* File picker — works on every platform. Phrased so the copy
|
||||
fits both mobile (library/files) and desktop (drag and drop). */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -148,18 +235,30 @@ export default function ScanReceiptPage() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-5 w-5" />
|
||||
<span className="sm:hidden">Choose from library</span>
|
||||
<span className="hidden sm:inline">Click to upload or drag and drop</span>
|
||||
<span className="sm:hidden">Choose from device</span>
|
||||
<span className="hidden sm:inline">Choose from device or drag and drop</span>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground sm:col-span-2 text-center">
|
||||
JPEG, PNG, WebP up to 10MB
|
||||
JPEG, PNG, HEIC, WebP up to 10 MB
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground sm:col-span-2 text-center">
|
||||
Have many receipts?{' '}
|
||||
<a
|
||||
href={`/${params.portSlug}/expenses/bulk-upload`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Bulk upload →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* `image/*` is the broadest accept — includes HEIC on iOS,
|
||||
JPEG/PNG/WebP everywhere. The capture attribute on the second
|
||||
input invokes the native camera flow on mobile. */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/*,application/pdf"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
@@ -264,10 +363,20 @@ export default function ScanReceiptPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !amount}
|
||||
disabled={
|
||||
saveMutation.isPending ||
|
||||
!amount ||
|
||||
// Block save while the receipt upload is still in flight —
|
||||
// otherwise the rep can hit Save before the storage round
|
||||
// trip finishes and the expense lands without `receiptFileIds`,
|
||||
// silently re-creating the legacy receipt-loss bug.
|
||||
uploadMutation.isPending
|
||||
}
|
||||
>
|
||||
{saveMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save as Expense
|
||||
{(saveMutation.isPending || uploadMutation.isPending) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{uploadMutation.isPending ? 'Uploading…' : 'Save as Expense'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user