diff --git a/src/routes/(app)/companies/[companyId]/documents/[docId]/+page.server.ts b/src/routes/(app)/companies/[companyId]/documents/[docId]/+page.server.ts new file mode 100644 index 0000000..2f8feb7 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/documents/[docId]/+page.server.ts @@ -0,0 +1,256 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { + companyDocuments, + companyDocumentVersions, + users +} from '$lib/server/db/schema.js'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; +import { + saveCompanyFile, + deleteCompanyFile, + isAllowedMime, + MAX_BYTES, + ALLOWED_MIME +} from '$lib/server/uploads/index.js'; +import { and, desc, eq, isNull, sql } from 'drizzle-orm'; + +const DOCUMENT_CATEGORIES = [ + 'dbd_registration', + 'affidavit', + 'memorandum', + 'articles_of_association', + 'vat_registration', + 'tax_id_document', + 'bank_document', + 'director_id', + 'director_signature_card', + 'shareholder_list', + 'annual_filing', + 'contract', + 'license', + 'insurance', + 'other' +] as const; + +type DocumentCategory = (typeof DOCUMENT_CATEGORIES)[number]; + +function trimOrNull(v: FormDataEntryValue | null): string | null { + const s = v?.toString().trim(); + return s ? s : null; +} + +export const load: PageServerLoad = async ({ locals, params, parent }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + await parent(); + + const [doc] = await db + .select() + .from(companyDocuments) + .where( + and( + eq(companyDocuments.id, params.docId), + eq(companyDocuments.companyId, params.companyId), + isNull(companyDocuments.deletedAt) + ) + ) + .limit(1); + + if (!doc) error(404, 'Document not found'); + + const versions = await db + .select({ + id: companyDocumentVersions.id, + versionNumber: companyDocumentVersions.versionNumber, + fileName: companyDocumentVersions.fileName, + mimeType: companyDocumentVersions.mimeType, + sizeBytes: companyDocumentVersions.sizeBytes, + uploadedAt: companyDocumentVersions.uploadedAt, + comment: companyDocumentVersions.comment, + uploadedByName: users.displayName, + uploadedByEmail: users.email + }) + .from(companyDocumentVersions) + .leftJoin(users, eq(companyDocumentVersions.uploadedBy, users.id)) + .where(eq(companyDocumentVersions.documentId, doc.id)) + .orderBy(desc(companyDocumentVersions.versionNumber)); + + return { document: doc, versions }; +}; + +export const actions: Actions = { + addVersion: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'accountant' + ]); + + const [doc] = await db + .select({ id: companyDocuments.id, title: companyDocuments.title }) + .from(companyDocuments) + .where( + and( + eq(companyDocuments.id, params.docId), + eq(companyDocuments.companyId, params.companyId), + isNull(companyDocuments.deletedAt) + ) + ) + .limit(1); + if (!doc) error(404, 'Document not found'); + + const fd = await request.formData(); + const file = fd.get('file') as File | null; + const comment = trimOrNull(fd.get('comment')); + + if (!file || !(file instanceof File) || file.size === 0) { + return fail(400, { action: 'addVersion', error: 'File is required' }); + } + if (file.size > MAX_BYTES) { + return fail(400, { + action: 'addVersion', + error: `File too large (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB)` + }); + } + const mime = file.type || 'application/octet-stream'; + if (!isAllowedMime(mime)) { + return fail(400, { + action: 'addVersion', + error: `File type not allowed. Allowed: ${ALLOWED_MIME.join(', ')}` + }); + } + + const [maxRow] = await db + .select({ max: sql`coalesce(max(${companyDocumentVersions.versionNumber}), 0)::int` }) + .from(companyDocumentVersions) + .where(eq(companyDocumentVersions.documentId, doc.id)); + const nextVersion = (maxRow?.max ?? 0) + 1; + + let saved; + try { + saved = await saveCompanyFile(params.companyId, file); + } catch (err) { + console.error('saveCompanyFile failed', err); + return fail(500, { action: 'addVersion', error: 'Failed to save file to disk' }); + } + + try { + await db.insert(companyDocumentVersions).values({ + documentId: doc.id, + versionNumber: nextVersion, + fileName: file.name, + storedPath: saved.storedPath, + mimeType: saved.mimeType, + sizeBytes: saved.sizeBytes, + uploadedBy: user.id, + comment + }); + await db + .update(companyDocuments) + .set({ updatedAt: new Date() }) + .where(eq(companyDocuments.id, doc.id)); + } catch (err) { + await deleteCompanyFile(saved.storedPath); + console.error('insert version failed', err); + return fail(500, { action: 'addVersion', error: 'Failed to register file version' }); + } + + await logCompanyEvent( + params.companyId, + user.id, + 'document_version_added', + `New version v${nextVersion} uploaded for "${doc.title}"`, + { documentId: doc.id, versionNumber: nextVersion, fileName: file.name } + ); + + return { success: true, action: 'addVersion' }; + }, + + updateMetadata: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'accountant' + ]); + + const [doc] = await db + .select({ id: companyDocuments.id }) + .from(companyDocuments) + .where( + and( + eq(companyDocuments.id, params.docId), + eq(companyDocuments.companyId, params.companyId), + isNull(companyDocuments.deletedAt) + ) + ) + .limit(1); + if (!doc) error(404, 'Document not found'); + + const fd = await request.formData(); + const title = trimOrNull(fd.get('title')); + const category = fd.get('category')?.toString() as DocumentCategory | undefined; + + if (!title) return fail(400, { action: 'updateMetadata', error: 'Title is required' }); + if (!category || !DOCUMENT_CATEGORIES.includes(category)) { + return fail(400, { action: 'updateMetadata', error: 'Invalid category' }); + } + + await db + .update(companyDocuments) + .set({ + title, + category, + customLabel: trimOrNull(fd.get('customLabel')), + description: trimOrNull(fd.get('description')), + expiresAt: trimOrNull(fd.get('expiresAt')) ?? null, + notes: trimOrNull(fd.get('notes')), + updatedAt: new Date() + }) + .where(eq(companyDocuments.id, doc.id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'document_metadata_updated', + `Document "${title}" metadata updated`, + { documentId: doc.id } + ); + + return { success: true, action: 'updateMetadata' }; + }, + + softDelete: async ({ locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'accountant' + ]); + + const [doc] = await db + .select({ id: companyDocuments.id, title: companyDocuments.title }) + .from(companyDocuments) + .where( + and( + eq(companyDocuments.id, params.docId), + eq(companyDocuments.companyId, params.companyId), + isNull(companyDocuments.deletedAt) + ) + ) + .limit(1); + if (!doc) error(404, 'Document not found'); + + await db + .update(companyDocuments) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(companyDocuments.id, doc.id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'document_deleted', + `Document "${doc.title}" deleted`, + { documentId: doc.id } + ); + + redirect(303, `/companies/${params.companyId}/documents`); + } +}; diff --git a/src/routes/(app)/companies/[companyId]/documents/[docId]/+page.svelte b/src/routes/(app)/companies/[companyId]/documents/[docId]/+page.svelte new file mode 100644 index 0000000..fe68f2f --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/documents/[docId]/+page.svelte @@ -0,0 +1,402 @@ + + + + {data.document.title} - {data.company.name} + + +
+ + + {#if form?.error} +
+ {form.error} +
+ {/if} + + +
+ {#if editing} +
async ({ update }) => { + await update({ reset: false }); + editing = false; + }} + class="grid grid-cols-1 gap-3 sm:grid-cols-2" + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {:else} +
+
+
+

{data.document.title}

+ + {CATEGORY_LABELS[data.document.category]} + +
+ {#if data.document.customLabel} +

{data.document.customLabel}

+ {/if} +
+ {#if canEdit} +
+ + +
+ {/if} +
+ +
+ {#if data.document.description} +
+
+ Description +
+
+ {data.document.description} +
+
+ {/if} +
+
+ Expiry Date +
+
+ {data.document.expiresAt ? formatDate(data.document.expiresAt) : '—'} + {#if data.document.expiresAt && isExpired(data.document.expiresAt)} + (expired) + {/if} +
+
+
+
Created
+
+ {formatDateTime(data.document.createdAt)} +
+
+
+
Updated
+
+ {formatDateTime(data.document.updatedAt)} +
+
+ {#if data.document.notes} +
+
Notes
+
+ {data.document.notes} +
+
+ {/if} +
+ + {#if confirmDelete && canEdit} +
+

+ Delete this document? Files will remain on disk for recovery, but the document will be + hidden from the list. +

+
+ +
+ +
+
+
+ {/if} + {/if} +
+ + +
+
+

+ Versions ({data.versions.length}) +

+ {#if canEdit} + + {/if} +
+ + {#if showAddVersion && canEdit} +
async ({ update }) => { + await update({ reset: false }); + showAddVersion = false; + }} + class="grid grid-cols-1 gap-3 border-b border-gray-200 bg-blue-50 p-4 dark:border-gray-700 dark:bg-blue-900/20" + > +
+ + +

+ PDF, image, or Office document up to 25 MB. +

+
+
+ + +
+
+ +
+
+ {/if} + + {#if data.versions.length === 0} +

No versions yet.

+ {:else} +
    + {#each data.versions as v} +
  • +
    +
    +
    + {mimeIcon(v.mimeType)} + v{v.versionNumber} + + {v.fileName} + +
    +
    + {humanSize(v.sizeBytes)} · {v.mimeType} · uploaded + {formatDateTime(v.uploadedAt)} by + {v.uploadedByName ?? v.uploadedByEmail ?? 'unknown'} +
    + {#if v.comment} +
    + {v.comment} +
    + {/if} +
    + + Download + +
    +
  • + {/each} +
+ {/if} +
+
diff --git a/src/routes/(app)/companies/[companyId]/documents/[docId]/versions/[versionId]/file/+server.ts b/src/routes/(app)/companies/[companyId]/documents/[docId]/versions/[versionId]/file/+server.ts new file mode 100644 index 0000000..6cc4bfc --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/documents/[docId]/versions/[versionId]/file/+server.ts @@ -0,0 +1,48 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { companyDocuments, companyDocumentVersions } from '$lib/server/db/schema.js'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { readCompanyFile } from '$lib/server/uploads/index.js'; +import { and, eq, isNull } from 'drizzle-orm'; + +export const GET: RequestHandler = async ({ locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + + const [row] = await db + .select({ + storedPath: companyDocumentVersions.storedPath, + fileName: companyDocumentVersions.fileName, + mimeType: companyDocumentVersions.mimeType + }) + .from(companyDocumentVersions) + .innerJoin(companyDocuments, eq(companyDocumentVersions.documentId, companyDocuments.id)) + .where( + and( + eq(companyDocumentVersions.id, params.versionId), + eq(companyDocuments.id, params.docId), + eq(companyDocuments.companyId, params.companyId), + isNull(companyDocuments.deletedAt) + ) + ) + .limit(1); + + if (!row) error(404, 'File not found'); + + let buf: Buffer; + try { + buf = await readCompanyFile(row.storedPath); + } catch (err) { + console.error('readCompanyFile failed', err); + error(404, 'File missing on disk'); + } + + const safeName = row.fileName.replace(/[\r\n"\\]/g, '_'); + + return new Response(new Blob([buf as BlobPart], { type: row.mimeType }), { + headers: { + 'Content-Disposition': `inline; filename="${safeName}"`, + 'Cache-Control': 'private, no-store' + } + }); +};