Add company documents schema, uploads helper, and env wiring
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,3 +14,7 @@ OIDC_ISSUER_URL=
|
|||||||
OIDC_CLIENT_ID=
|
OIDC_CLIENT_ID=
|
||||||
OIDC_CLIENT_SECRET=
|
OIDC_CLIENT_SECRET=
|
||||||
OIDC_REDIRECT_URI=http://localhost:3000/oidc/callback
|
OIDC_REDIRECT_URI=http://localhost:3000/oidc/callback
|
||||||
|
|
||||||
|
# Document uploads
|
||||||
|
UPLOADS_DIR=./uploads
|
||||||
|
BODY_SIZE_LIMIT=26214400
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ build/
|
|||||||
*.db-journal
|
*.db-journal
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist/
|
dist/
|
||||||
|
/uploads/
|
||||||
|
|||||||
@@ -673,6 +673,68 @@ export const featureRequestVotes = pgTable(
|
|||||||
(table) => [uniqueIndex('feature_request_votes_request_user_idx').on(table.requestId, table.userId)]
|
(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) ──
|
// ── Company Profile (bank accounts, cards, addresses) ──
|
||||||
|
|
||||||
export const companyAddressTypeEnum = pgEnum('company_address_type', [
|
export const companyAddressTypeEnum = pgEnum('company_address_type', [
|
||||||
@@ -819,7 +881,11 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
|||||||
'card_removed',
|
'card_removed',
|
||||||
'address_added',
|
'address_added',
|
||||||
'address_updated',
|
'address_updated',
|
||||||
'address_removed'
|
'address_removed',
|
||||||
|
'document_uploaded',
|
||||||
|
'document_version_added',
|
||||||
|
'document_metadata_updated',
|
||||||
|
'document_deleted'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const companyLog = pgTable(
|
export const companyLog = pgTable(
|
||||||
|
|||||||
@@ -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<SavedFile> {
|
||||||
|
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<Buffer> {
|
||||||
|
const absPath = path.join(uploadsRoot(), relPath);
|
||||||
|
return readFile(absPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort delete; ignores missing files. */
|
||||||
|
export async function deleteCompanyFile(relPath: string): Promise<void> {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user