From f69313bf337c9daf4c771a73a02f11af193a1081 Mon Sep 17 00:00:00 2001 From: grabowski Date: Wed, 15 Apr 2026 10:52:06 +0700 Subject: [PATCH] Add company documents schema, uploads helper, and env wiring Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 4 ++ .gitignore | 1 + src/lib/server/db/schema.ts | 68 +++++++++++++++++++++++++++++- src/lib/server/uploads/index.ts | 73 +++++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/uploads/index.ts diff --git a/.env.example b/.env.example index 01cc93f..2518299 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,7 @@ OIDC_ISSUER_URL= OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_REDIRECT_URI=http://localhost:3000/oidc/callback + +# Document uploads +UPLOADS_DIR=./uploads +BODY_SIZE_LIMIT=26214400 diff --git a/.gitignore b/.gitignore index 8b77474..43a6e4e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ *.db-journal .DS_Store dist/ +/uploads/ diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 8234968..1cd675d 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -673,6 +673,68 @@ export const featureRequestVotes = pgTable( (table) => [uniqueIndex('feature_request_votes_request_user_idx').on(table.requestId, table.userId)] ); +// ── Company Documents ────────────────────────────────── + +export const companyDocumentCategoryEnum = pgEnum('company_document_category', [ + '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' +]); + +export const companyDocuments = pgTable( + 'company_documents', + { + id: uuid('id').primaryKey().defaultRandom(), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id, { onDelete: 'cascade' }), + category: companyDocumentCategoryEnum('category').notNull(), + customLabel: text('custom_label'), + title: text('title').notNull(), + description: text('description'), + expiresAt: date('expires_at'), + notes: text('notes'), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() + }, + (table) => [index('company_documents_company_category_idx').on(table.companyId, table.category)] +); + +export const companyDocumentVersions = pgTable( + 'company_document_versions', + { + id: uuid('id').primaryKey().defaultRandom(), + documentId: uuid('document_id') + .notNull() + .references(() => companyDocuments.id, { onDelete: 'cascade' }), + versionNumber: integer('version_number').notNull(), + fileName: text('file_name').notNull(), + storedPath: text('stored_path').notNull(), + mimeType: text('mime_type').notNull(), + sizeBytes: integer('size_bytes').notNull(), + uploadedBy: text('uploaded_by').references(() => users.id, { onDelete: 'set null' }), + uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(), + comment: text('comment') + }, + (table) => [ + uniqueIndex('company_document_versions_doc_version_idx').on(table.documentId, table.versionNumber) + ] +); + // ── Company Profile (bank accounts, cards, addresses) ── export const companyAddressTypeEnum = pgEnum('company_address_type', [ @@ -819,7 +881,11 @@ export const companyLogEventEnum = pgEnum('company_log_event', [ 'card_removed', 'address_added', 'address_updated', - 'address_removed' + 'address_removed', + 'document_uploaded', + 'document_version_added', + 'document_metadata_updated', + 'document_deleted' ]); export const companyLog = pgTable( diff --git a/src/lib/server/uploads/index.ts b/src/lib/server/uploads/index.ts new file mode 100644 index 0000000..1d8d315 --- /dev/null +++ b/src/lib/server/uploads/index.ts @@ -0,0 +1,73 @@ +import { mkdir, writeFile, unlink, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { env } from '$env/dynamic/private'; + +/** Returns the configured uploads directory (env override or ./uploads in dev). */ +export function uploadsRoot(): string { + return env.UPLOADS_DIR ?? './uploads'; +} + +export interface SavedFile { + storedPath: string; // relative to uploadsRoot() + sizeBytes: number; + mimeType: string; +} + +/** + * Save an uploaded File under {uploadsRoot}/{companyId}/{uuid}{ext}. + * Returns metadata for persisting in companyDocumentVersions. + */ +export async function saveCompanyFile(companyId: string, file: File): Promise { + const ext = path.extname(file.name).toLowerCase(); + const id = crypto.randomUUID(); + const relPath = path.posix.join(companyId, `${id}${ext}`); + const absPath = path.join(uploadsRoot(), companyId, `${id}${ext}`); + await mkdir(path.dirname(absPath), { recursive: true }); + const buf = Buffer.from(await file.arrayBuffer()); + await writeFile(absPath, buf); + return { + storedPath: relPath, + sizeBytes: buf.length, + mimeType: file.type || 'application/octet-stream' + }; +} + +/** Read a file by its stored relative path. */ +export async function readCompanyFile(relPath: string): Promise { + const absPath = path.join(uploadsRoot(), relPath); + return readFile(absPath); +} + +/** Best-effort delete; ignores missing files. */ +export async function deleteCompanyFile(relPath: string): Promise { + try { + await unlink(path.join(uploadsRoot(), relPath)); + } catch { + /* file already gone — fine */ + } +} + +export const ALLOWED_MIME = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/heic', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' +]; + +export const MAX_BYTES = 25 * 1024 * 1024; // 25 MB + +export function isAllowedMime(mime: string): boolean { + return ALLOWED_MIME.includes(mime); +} + +export function humanSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +}