diff --git a/src/routes/(app)/companies/[companyId]/documents/+page.server.ts b/src/routes/(app)/companies/[companyId]/documents/+page.server.ts new file mode 100644 index 0000000..a988899 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/documents/+page.server.ts @@ -0,0 +1,231 @@ +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, inArray, 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, url }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + await parent(); + + const categoryFilter = url.searchParams.get('category') as DocumentCategory | null; + const isValidCategory = categoryFilter && DOCUMENT_CATEGORIES.includes(categoryFilter); + + const whereClauses = [ + eq(companyDocuments.companyId, params.companyId), + isNull(companyDocuments.deletedAt) + ]; + if (isValidCategory) { + whereClauses.push(eq(companyDocuments.category, categoryFilter)); + } + + const docs = await db + .select({ + id: companyDocuments.id, + category: companyDocuments.category, + customLabel: companyDocuments.customLabel, + title: companyDocuments.title, + description: companyDocuments.description, + expiresAt: companyDocuments.expiresAt, + notes: companyDocuments.notes, + createdAt: companyDocuments.createdAt, + updatedAt: companyDocuments.updatedAt, + createdByName: users.displayName, + createdByEmail: users.email + }) + .from(companyDocuments) + .leftJoin(users, eq(companyDocuments.createdBy, users.id)) + .where(and(...whereClauses)) + .orderBy(desc(companyDocuments.updatedAt)); + + // Collect latest version info per document. + const docIds = docs.map((d) => d.id); + const latestByDoc = new Map< + string, + { + versionNumber: number; + fileName: string; + mimeType: string; + sizeBytes: number; + uploadedAt: Date; + versionCount: number; + } + >(); + + if (docIds.length > 0) { + const versionRows = await db + .select({ + documentId: companyDocumentVersions.documentId, + versionNumber: companyDocumentVersions.versionNumber, + fileName: companyDocumentVersions.fileName, + mimeType: companyDocumentVersions.mimeType, + sizeBytes: companyDocumentVersions.sizeBytes, + uploadedAt: companyDocumentVersions.uploadedAt + }) + .from(companyDocumentVersions) + .where(inArray(companyDocumentVersions.documentId, docIds)) + .orderBy(desc(companyDocumentVersions.versionNumber)); + + const countRows = await db + .select({ + documentId: companyDocumentVersions.documentId, + count: sql`count(*)::int` + }) + .from(companyDocumentVersions) + .where(inArray(companyDocumentVersions.documentId, docIds)) + .groupBy(companyDocumentVersions.documentId); + + const countByDoc = new Map(countRows.map((r) => [r.documentId, r.count])); + + for (const v of versionRows) { + if (!latestByDoc.has(v.documentId)) { + latestByDoc.set(v.documentId, { + versionNumber: v.versionNumber, + fileName: v.fileName, + mimeType: v.mimeType, + sizeBytes: v.sizeBytes, + uploadedAt: v.uploadedAt, + versionCount: countByDoc.get(v.documentId) ?? 1 + }); + } + } + } + + const documents = docs.map((d) => ({ + ...d, + latest: latestByDoc.get(d.id) ?? null + })); + + return { + documents, + categoryFilter: isValidCategory ? categoryFilter : null + }; +}; + +export const actions: Actions = { + upload: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'accountant' + ]); + + const fd = await request.formData(); + const category = fd.get('category')?.toString() as DocumentCategory | undefined; + const title = trimOrNull(fd.get('title')); + const file = fd.get('file') as File | null; + + if (!category || !DOCUMENT_CATEGORIES.includes(category)) { + return fail(400, { error: 'Category is required' }); + } + if (!title) return fail(400, { error: 'Title is required' }); + if (!file || !(file instanceof File) || file.size === 0) { + return fail(400, { error: 'File is required' }); + } + if (file.size > MAX_BYTES) { + return fail(400, { 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, { + error: `File type not allowed. Allowed: ${ALLOWED_MIME.join(', ')}` + }); + } + + const customLabel = trimOrNull(fd.get('customLabel')); + const description = trimOrNull(fd.get('description')); + const expiresAt = trimOrNull(fd.get('expiresAt')); + const notes = trimOrNull(fd.get('notes')); + const comment = trimOrNull(fd.get('comment')); + + // Insert document row first so we can link the version to it. + const [newDoc] = await db + .insert(companyDocuments) + .values({ + companyId: params.companyId, + category, + customLabel, + title, + description, + expiresAt: expiresAt ?? undefined, + notes, + createdBy: user.id + }) + .returning({ id: companyDocuments.id }); + + let saved; + try { + saved = await saveCompanyFile(params.companyId, file); + } catch (err) { + // roll back the document row on disk failure + await db.delete(companyDocuments).where(eq(companyDocuments.id, newDoc.id)); + console.error('saveCompanyFile failed', err); + return fail(500, { error: 'Failed to save file to disk' }); + } + + try { + await db.insert(companyDocumentVersions).values({ + documentId: newDoc.id, + versionNumber: 1, + fileName: file.name, + storedPath: saved.storedPath, + mimeType: saved.mimeType, + sizeBytes: saved.sizeBytes, + uploadedBy: user.id, + comment + }); + } catch (err) { + await deleteCompanyFile(saved.storedPath); + await db.delete(companyDocuments).where(eq(companyDocuments.id, newDoc.id)); + console.error('insert version failed', err); + return fail(500, { error: 'Failed to register file version' }); + } + + await logCompanyEvent( + params.companyId, + user.id, + 'document_uploaded', + `Document "${title}" uploaded (${category})`, + { documentId: newDoc.id, fileName: file.name, sizeBytes: saved.sizeBytes } + ); + + redirect(303, `/companies/${params.companyId}/documents/${newDoc.id}`); + } +}; diff --git a/src/routes/(app)/companies/[companyId]/documents/+page.svelte b/src/routes/(app)/companies/[companyId]/documents/+page.svelte new file mode 100644 index 0000000..e633e07 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/documents/+page.svelte @@ -0,0 +1,311 @@ + + + + Documents - {data.company.name} + + +
+
+

Company Documents

+

+ DBD registration, MOA, director IDs, licences, contracts, and other legal paperwork. Visible to + admin, manager, and accountant. Upload and delete are admin/accountant only. +

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + + {#if canUpload} +
+
+

Upload Document

+ +
+ + {#if showUpload} +
async ({ update }) => { + await update({ reset: false }); + }} + class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2" + > +
+ + +

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

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ {/if} +
+ {/if} + + +
+ + All + + {#each ALL_CATEGORIES as cat} + + {CATEGORY_LABELS[cat]} + + {/each} +
+ + +
+ +
+ + +
+ {#if filteredDocuments.length === 0} +

+ {data.documents.length === 0 + ? 'No documents uploaded yet.' + : 'No documents match your search.'} +

+ {:else} +
+ + + + + + + + + + + + + {#each filteredDocuments as doc} + { + window.location.href = `/companies/${$page.params.companyId}/documents/${doc.id}`; + }} + > + + + + + + + + {/each} + +
TitleCategoryVersionsSizeExpiresUploaded
+
{doc.title}
+ {#if doc.customLabel} +
{doc.customLabel}
+ {/if} +
+ + {CATEGORY_LABELS[doc.category]} + + + {doc.latest?.versionCount ?? 0} + + {doc.latest ? humanSize(doc.latest.sizeBytes) : '—'} + + {#if doc.expiresAt} + + {formatDate(doc.expiresAt)} + + {:else} + + {/if} + + {doc.latest ? formatDateTime(doc.latest.uploadedAt) : '—'} +
+
+ {/if} +
+