Add document storage (local filesystem) + fix dashboard outstanding KPI
- Local file storage via STORAGE_PATH env var (replaces DO Spaces) - POST/GET/DELETE /api/cases/:caseId/documents + GET /api/documents/:docId/download - useDocuments hook + upload/list/download/delete UI in CaseDetailPage - GET /api/invoices/summary — live SUM of sent+overdue invoices - Dashboard outstanding KPI wired to real DB value - api.upload() for FormData, formatBytes() utility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
0700d54225
commit
ce2278f4be
@@ -19,6 +19,7 @@ const envSchema = z.object({
|
||||
WEB_DIST_PATH: z.string().optional(),
|
||||
SUPERADMIN_EMAILS: z.string().optional().default(''),
|
||||
SENTRY_DSN_API: z.string().optional().default(''),
|
||||
STORAGE_PATH: z.string().min(1).default('./storage'),
|
||||
RESEND_API_KEY: z.string().optional().default(''),
|
||||
EMAIL_FROM: z.string().optional().default('eLegal Software <noreply@elegalsoftware.com>'),
|
||||
STRIPE_SECRET_KEY: z.string().optional().default(''),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { env } from '../env';
|
||||
|
||||
function root(): string {
|
||||
return path.resolve(env.STORAGE_PATH);
|
||||
}
|
||||
|
||||
function resolve(key: string): string {
|
||||
const abs = path.resolve(root(), key);
|
||||
if (!abs.startsWith(root() + path.sep) && abs !== root()) {
|
||||
throw new Error('invalid_storage_key');
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
export async function saveFile(key: string, data: Buffer): Promise<void> {
|
||||
const dest = resolve(key);
|
||||
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fs.promises.writeFile(dest, data);
|
||||
}
|
||||
|
||||
export async function deleteFile(key: string): Promise<void> {
|
||||
await fs.promises.unlink(resolve(key));
|
||||
}
|
||||
|
||||
export function createReadStream(key: string): fs.ReadStream {
|
||||
return fs.createReadStream(resolve(key));
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { getDb, documents, cases } from '@lawdesk/db';
|
||||
import { saveFile, deleteFile, createReadStream } from '../lib/storage';
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
export async function documentsRoutes(app: FastifyInstance) {
|
||||
app.addHook('preHandler', app.requireFirm);
|
||||
|
||||
// ── List documents for a case ──────────────────────────────────────────────
|
||||
app.get('/api/cases/:caseId/documents', async (req, reply) => {
|
||||
const firmId = req.user!.firmId!;
|
||||
const caseId = z.string().uuid().parse((req.params as { caseId: string }).caseId);
|
||||
const db = getDb();
|
||||
|
||||
const [c] = await db.select({ id: cases.id }).from(cases)
|
||||
.where(and(eq(cases.id, caseId), eq(cases.firmId, firmId))).limit(1);
|
||||
if (!c) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
name: documents.name,
|
||||
mimeType: documents.mimeType,
|
||||
sizeBytes: documents.sizeBytes,
|
||||
createdAt: documents.createdAt,
|
||||
})
|
||||
.from(documents)
|
||||
.where(and(eq(documents.caseId, caseId), eq(documents.firmId, firmId)))
|
||||
.orderBy(desc(documents.createdAt));
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
// ── Upload a document ──────────────────────────────────────────────────────
|
||||
app.post('/api/cases/:caseId/documents', async (req, reply) => {
|
||||
const firmId = req.user!.firmId!;
|
||||
const userId = req.user!.id;
|
||||
const caseId = z.string().uuid().parse((req.params as { caseId: string }).caseId);
|
||||
const db = getDb();
|
||||
|
||||
const [c] = await db.select({ id: cases.id }).from(cases)
|
||||
.where(and(eq(cases.id, caseId), eq(cases.firmId, firmId))).limit(1);
|
||||
if (!c) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
const data = await req.file({ limits: { fileSize: MAX_BYTES } });
|
||||
if (!data) return reply.code(400).send({ error: 'no_file' });
|
||||
if (!ALLOWED_MIME.has(data.mimetype)) {
|
||||
return reply.code(400).send({ error: 'invalid_file_type' });
|
||||
}
|
||||
|
||||
const buf = await data.toBuffer();
|
||||
const docId = randomUUID();
|
||||
const ext = path.extname(data.filename);
|
||||
const storageKey = `${firmId}/${caseId}/${docId}${ext}`;
|
||||
|
||||
await saveFile(storageKey, buf);
|
||||
|
||||
const [doc] = await db.insert(documents).values({
|
||||
id: docId,
|
||||
firmId,
|
||||
caseId,
|
||||
uploadedBy: userId,
|
||||
name: data.filename,
|
||||
storageKey,
|
||||
mimeType: data.mimetype,
|
||||
sizeBytes: buf.length,
|
||||
}).returning();
|
||||
|
||||
return reply.code(201).send({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
mimeType: doc.mimeType,
|
||||
sizeBytes: doc.sizeBytes,
|
||||
createdAt: doc.createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Download a document ────────────────────────────────────────────────────
|
||||
app.get('/api/documents/:docId/download', async (req, reply) => {
|
||||
const firmId = req.user!.firmId!;
|
||||
const docId = z.string().uuid().parse((req.params as { docId: string }).docId);
|
||||
const db = getDb();
|
||||
|
||||
const [doc] = await db.select().from(documents)
|
||||
.where(and(eq(documents.id, docId), eq(documents.firmId, firmId))).limit(1);
|
||||
if (!doc) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
const stream = createReadStream(doc.storageKey);
|
||||
return reply
|
||||
.header('Content-Type', doc.mimeType)
|
||||
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(doc.name)}"`)
|
||||
.header('Content-Length', String(doc.sizeBytes))
|
||||
.send(stream);
|
||||
});
|
||||
|
||||
// ── Delete a document ──────────────────────────────────────────────────────
|
||||
app.delete('/api/documents/:docId', async (req, reply) => {
|
||||
const firmId = req.user!.firmId!;
|
||||
const docId = z.string().uuid().parse((req.params as { docId: string }).docId);
|
||||
const db = getDb();
|
||||
|
||||
const [doc] = await db.select().from(documents)
|
||||
.where(and(eq(documents.id, docId), eq(documents.firmId, firmId))).limit(1);
|
||||
if (!doc) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
await db.delete(documents).where(eq(documents.id, docId));
|
||||
|
||||
try {
|
||||
await deleteFile(doc.storageKey);
|
||||
} catch (err) {
|
||||
// DB row is already gone — log but don't fail the request.
|
||||
req.log.warn({ err, storageKey: doc.storageKey }, 'orphaned file after delete');
|
||||
}
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -115,6 +115,16 @@ export async function invoicesRoutes(app: FastifyInstance) {
|
||||
return { items: rows, total: count?.total ?? 0 };
|
||||
});
|
||||
|
||||
// Summary — outstanding total across sent + overdue invoices
|
||||
app.get('/api/invoices/summary', async (req) => {
|
||||
const firmId = req.user!.firmId!;
|
||||
const [row] = await getDb()
|
||||
.select({ outstanding: sql<string>`COALESCE(SUM(${invoices.total}), 0)` })
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.firmId, firmId), inArray(invoices.status, ['sent', 'overdue'])));
|
||||
return { outstanding: row?.outstanding ?? '0' };
|
||||
});
|
||||
|
||||
// Get with items
|
||||
app.get('/api/invoices/:id', async (req, reply) => {
|
||||
const firmId = req.user!.firmId!;
|
||||
|
||||
@@ -7,6 +7,7 @@ import cookie from '@fastify/cookie';
|
||||
import helmet from '@fastify/helmet';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import staticPlugin from '@fastify/static';
|
||||
import multipart from '@fastify/multipart';
|
||||
import { env, isProd } from './env';
|
||||
import { initSentry, captureError } from './lib/sentry';
|
||||
import { authPlugin } from './auth/plugin';
|
||||
@@ -22,6 +23,7 @@ import { adminRoutes } from './routes/admin';
|
||||
import { accountRoutes } from './routes/account';
|
||||
import { toolUsageRoutes } from './routes/tool-usage';
|
||||
import { billingRoutes } from './routes/billing';
|
||||
import { documentsRoutes } from './routes/documents';
|
||||
import { stripeWebhookRoute } from './routes/webhooks-stripe';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -82,6 +84,8 @@ export async function buildServer() {
|
||||
secret: env.SESSION_SECRET,
|
||||
});
|
||||
|
||||
await app.register(multipart);
|
||||
|
||||
// Global rate limit floor — per-route limits override below.
|
||||
await app.register(rateLimit, {
|
||||
global: true,
|
||||
@@ -108,6 +112,7 @@ export async function buildServer() {
|
||||
await app.register(accountRoutes);
|
||||
await app.register(toolUsageRoutes);
|
||||
await app.register(billingRoutes);
|
||||
await app.register(documentsRoutes);
|
||||
|
||||
// Serve the built SPA in production. In dev, the Vite dev server runs separately.
|
||||
const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');
|
||||
|
||||
Reference in New Issue
Block a user