Bug/year end failure (#575)
* feat: implement findNextPeriod function and integrate into year-end closing logic * feat: add integrity check for PDF documents and enhance user feedback for corrupt files * Refactor year-end service and period creation logic for improved UTC handling and error messaging - Update `validateYearEndReadiness` to assert on stable warning messages without interpolating period names. - Modify `createNextPeriod` to ensure date calculations are performed in UTC, preventing DST-related issues. - Enhance error handling in `validateYearEndReadiness` and `executeYearEndClosing` to avoid exposing database details. - Introduce structured error messages for year-end processes in `structured-errors.ts`. - Add tests for document integrity checks, ensuring proper authentication and error handling. - Implement GUC checks in document versioning to prevent unauthorized modifications and ensure company membership. - Update migration scripts to reflect changes in document immutability enforcement. * fix: add comment to clarify GUC behavior in document supersession logic
This commit is contained in:
@@ -41,6 +41,15 @@ interface AttachmentPreviewSheetProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// Tri-state integrity result so a transport/parse failure does not get
|
||||
// collapsed into "valid". Document bytes are immutable (WORM), so once we
|
||||
// have a definitive valid/invalid we can also memoise across re-opens of
|
||||
// the sheet — the integrity probe is the most expensive call on this
|
||||
// surface and there's no need to re-run it for the same document twice in
|
||||
// a session.
|
||||
type IntegrityState = 'valid' | 'invalid' | 'error'
|
||||
const integrityCache = new Map<string, IntegrityState>()
|
||||
|
||||
function isImageType(type: string | null): boolean {
|
||||
return type?.startsWith('image/') ?? false
|
||||
}
|
||||
@@ -65,6 +74,7 @@ export default function AttachmentPreviewSheet({
|
||||
const { toast } = useToast()
|
||||
const [documents, setDocuments] = useState<DocumentRecord[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [integrity, setIntegrity] = useState<Record<string, IntegrityState>>({})
|
||||
|
||||
const [blockedDoc, setBlockedDoc] = useState<DocumentRecord | null>(null)
|
||||
const [replacingDocId, setReplacingDocId] = useState<string | null>(null)
|
||||
@@ -97,8 +107,61 @@ export default function AttachmentPreviewSheet({
|
||||
})
|
||||
)
|
||||
setDocuments(enriched)
|
||||
|
||||
// Probe storage bytes for PDFs that we haven't already classified in
|
||||
// this session. Legacy MCP uploads from before magic-byte validation
|
||||
// can have non-PDF bytes stored under mime_type='application/pdf';
|
||||
// Chrome's PDF viewer surfaces this as "Failed to load PDF document"
|
||||
// only after the user has tried to view the file, so we want a
|
||||
// clearer warning up front.
|
||||
//
|
||||
// Why per-session caching: document bytes are immutable (WORM) once
|
||||
// uploaded, so a definitive valid/invalid result never changes for
|
||||
// the same document id. Re-running the probe every time the sheet
|
||||
// opens would be wasted bandwidth and unnecessary processing of
|
||||
// financial documents (GDPR Art. 5(1)(b) data minimisation).
|
||||
const seeded: Record<string, IntegrityState> = {}
|
||||
const needsProbe: DocumentRecord[] = []
|
||||
for (const doc of enriched) {
|
||||
if (doc.mime_type !== 'application/pdf') continue
|
||||
const cached = integrityCache.get(doc.id)
|
||||
if (cached) {
|
||||
seeded[doc.id] = cached
|
||||
} else {
|
||||
needsProbe.push(doc)
|
||||
}
|
||||
}
|
||||
if (Object.keys(seeded).length > 0) {
|
||||
setIntegrity(seeded)
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
needsProbe.map(async (doc) => {
|
||||
try {
|
||||
const r = await fetch(`/api/documents/${doc.id}/integrity`)
|
||||
if (!r.ok) {
|
||||
// Server reachable but returned a non-2xx — treat as unknown.
|
||||
// Caching the error would stick across reloads of the sheet,
|
||||
// which is not what we want for transient 5xx.
|
||||
return [doc.id, 'error' as const, false] as const
|
||||
}
|
||||
const { data } = await r.json()
|
||||
const state: IntegrityState = data?.valid === false ? 'invalid' : 'valid'
|
||||
return [doc.id, state, true] as const
|
||||
} catch {
|
||||
return [doc.id, 'error' as const, false] as const
|
||||
}
|
||||
})
|
||||
)
|
||||
const next: Record<string, IntegrityState> = { ...seeded }
|
||||
for (const [docId, state, cache] of results) {
|
||||
next[docId] = state
|
||||
if (cache) integrityCache.set(docId, state)
|
||||
}
|
||||
setIntegrity(next)
|
||||
} catch {
|
||||
setDocuments([])
|
||||
setIntegrity({})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -109,6 +172,7 @@ export default function AttachmentPreviewSheet({
|
||||
fetchAttachments(entryId)
|
||||
} else if (!open) {
|
||||
setDocuments([])
|
||||
setIntegrity({})
|
||||
setBlockedDoc(null)
|
||||
}
|
||||
}, [open, entryId, fetchAttachments])
|
||||
@@ -243,36 +307,75 @@ export default function AttachmentPreviewSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPdfType(doc.mime_type) && (
|
||||
{isPdfType(doc.mime_type) && integrity[doc.id] === 'invalid' && (
|
||||
<div className="flex h-[70vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-border bg-muted/30 p-6 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-warning/15">
|
||||
<AlertTriangle className="h-5 w-5 text-warning-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-2">
|
||||
<p className="text-sm font-medium">{t('corrupt_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('corrupt_body')}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleOpenReplacePicker(doc.id)}
|
||||
disabled={isReplacing}
|
||||
>
|
||||
{isReplacing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{tj('replace_uploading')}
|
||||
</>
|
||||
) : (
|
||||
t('corrupt_replace_cta')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPdfType(doc.mime_type) && integrity[doc.id] !== 'invalid' && (
|
||||
// <object> + type="application/pdf" invokes Chrome's PDF
|
||||
// plugin directly. <iframe> went through Chrome's frame
|
||||
// pipeline first and intermittently surfaced
|
||||
// "Det här innehållet har blockerats" even with a
|
||||
// permissive CSP. Firefox/Edge handled both fine; Chrome
|
||||
// is the odd one. See crbug.com/271452.
|
||||
<object
|
||||
data={inlineSrc}
|
||||
type="application/pdf"
|
||||
aria-label={doc.file_name}
|
||||
className="h-[70vh] w-full rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
|
||||
{t('not_previewable')}
|
||||
{doc.download_url && (
|
||||
<>
|
||||
{' — '}
|
||||
<a
|
||||
href={doc.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t('open_in_new_tab')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</object>
|
||||
<>
|
||||
{integrity[doc.id] === 'error' && (
|
||||
// Non-blocking indicator when the integrity probe
|
||||
// could not complete (network blip, 5xx). The PDF
|
||||
// preview still renders; the user can fall back to
|
||||
// Chrome's own viewer error UI if the bytes are
|
||||
// also corrupt. Surfacing this rather than falling
|
||||
// through silently to "valid" satisfies SOC 2 CC8.1
|
||||
// — failed checks must not be masked.
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('integrity_unknown')}
|
||||
</p>
|
||||
)}
|
||||
<object
|
||||
data={inlineSrc}
|
||||
type="application/pdf"
|
||||
aria-label={doc.file_name}
|
||||
className="h-[70vh] w-full rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
|
||||
{t('not_previewable')}
|
||||
{doc.download_url && (
|
||||
<>
|
||||
{' — '}
|
||||
<a
|
||||
href={doc.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t('open_in_new_tab')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</object>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isImageType(doc.mime_type) && (
|
||||
|
||||
Reference in New Issue
Block a user