From ce2278f4befb523b78dcf8fbd9ca727952efde76 Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:07:37 -0400 Subject: [PATCH] Add document storage (local filesystem) + fix dashboard outstanding KPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 10 +- .gitignore | 1 + apps/api/src/env.ts | 1 + apps/api/src/lib/storage.ts | 29 +++++ apps/api/src/routes/documents.ts | 134 ++++++++++++++++++++++ apps/api/src/routes/invoices.ts | 10 ++ apps/api/src/server.ts | 5 + apps/web/src/hooks/useDocuments.ts | 40 +++++++ apps/web/src/hooks/useInvoices.ts | 7 ++ apps/web/src/lib/api.ts | 17 +++ apps/web/src/lib/format.ts | 6 + apps/web/src/pages/app/CaseDetailPage.tsx | 82 ++++++++++++- apps/web/src/pages/app/DashboardPage.tsx | 6 +- 13 files changed, 334 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/lib/storage.ts create mode 100644 apps/api/src/routes/documents.ts create mode 100644 apps/web/src/hooks/useDocuments.ts diff --git a/.env.example b/.env.example index c4ae7b1..57ad6c9 100644 --- a/.env.example +++ b/.env.example @@ -22,13 +22,11 @@ DATABASE_URL=postgresql://doadmin:password@db-postgresql-nyc1-xxxxx.b.db.ondigit DATABASE_CA_CERT_PATH=./certs/do-ca.crt # ───────────────────────────────────────────── -# DigitalOcean Spaces (S3-compatible) +# Local file storage +# Absolute path where uploaded documents are stored (outside web root). +# Production example: /var/www/vhosts/elegalsoftware.com/storage # ───────────────────────────────────────────── -SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com -SPACES_REGION=nyc3 -SPACES_BUCKET=lawdesk-uploads -SPACES_ACCESS_KEY= -SPACES_SECRET_KEY= +STORAGE_PATH=./storage # ───────────────────────────────────────────── # Email (Resend) diff --git a/.gitignore b/.gitignore index c5bc5c3..58562a0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ Thumbs.db tmp/restart.txt logs/ uploads/ +storage/ diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index cdcc618..108eda5 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -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 '), STRIPE_SECRET_KEY: z.string().optional().default(''), diff --git a/apps/api/src/lib/storage.ts b/apps/api/src/lib/storage.ts new file mode 100644 index 0000000..05fbe43 --- /dev/null +++ b/apps/api/src/lib/storage.ts @@ -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 { + 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 { + await fs.promises.unlink(resolve(key)); +} + +export function createReadStream(key: string): fs.ReadStream { + return fs.createReadStream(resolve(key)); +} diff --git a/apps/api/src/routes/documents.ts b/apps/api/src/routes/documents.ts new file mode 100644 index 0000000..60df49f --- /dev/null +++ b/apps/api/src/routes/documents.ts @@ -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(); + }); +} diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 6bb89c0..75fddd4 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -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`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!; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index d9267f8..92917a8 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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'); diff --git a/apps/web/src/hooks/useDocuments.ts b/apps/web/src/hooks/useDocuments.ts new file mode 100644 index 0000000..6ed6a69 --- /dev/null +++ b/apps/web/src/hooks/useDocuments.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api, type ApiError } from '@/lib/api'; + +export interface Document { + id: string; + name: string; + mimeType: string; + sizeBytes: number; + createdAt: string; +} + +const KEY = (caseId: string) => ['documents', caseId] as const; + +export function useDocuments(caseId: string | undefined) { + return useQuery({ + queryKey: caseId ? KEY(caseId) : ['documents', 'noop'], + queryFn: () => api.get(`/api/cases/${caseId}/documents`), + enabled: !!caseId, + }); +} + +export function useUploadDocument(caseId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (file) => { + const form = new FormData(); + form.append('file', file); + return api.upload(`/api/cases/${caseId}/documents`, form); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: KEY(caseId) }), + }); +} + +export function useDeleteDocument(caseId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (docId) => api.delete(`/api/documents/${docId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: KEY(caseId) }), + }); +} diff --git a/apps/web/src/hooks/useInvoices.ts b/apps/web/src/hooks/useInvoices.ts index 18d3684..42d9f2f 100644 --- a/apps/web/src/hooks/useInvoices.ts +++ b/apps/web/src/hooks/useInvoices.ts @@ -90,6 +90,13 @@ function qs(p: ListParams): string { return s ? `?${s}` : ''; } +export function useInvoiceSummary() { + return useQuery<{ outstanding: string }>({ + queryKey: ['invoices', 'summary'], + queryFn: () => api.get('/api/invoices/summary'), + }); +} + export function useInvoices(params: ListParams = {}) { return useQuery({ queryKey: KEY.list(params), diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c537d15..1d5bc15 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -42,10 +42,27 @@ async function request(method: string, path: string, body?: unknown): Promise return data as T; } +async function upload(path: string, form: FormData): Promise { + const headers: Record = {}; + const csrf = readCookie('csrf'); + if (csrf) headers['X-CSRF-Token'] = csrf; + const res = await fetch(path, { method: 'POST', credentials: 'same-origin', headers, body: form }); + const text = await res.text(); + const data = text ? JSON.parse(text) : null; + if (!res.ok) { + const err = new Error(data?.error ?? `upload_failed_${res.status}`) as ApiError; + err.status = res.status; + err.code = data?.error; + throw err; + } + return data as T; +} + export const api = { get: (path: string) => request('GET', path), post: (path: string, body?: unknown) => request('POST', path, body), put: (path: string, body?: unknown) => request('PUT', path, body), patch: (path: string, body?: unknown) => request('PATCH', path, body), delete: (path: string) => request('DELETE', path), + upload: (path: string, form: FormData) => upload(path, form), }; diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index a1ec93c..72b6f7d 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -16,6 +16,12 @@ export function formatMoney(amount: string | number | null | undefined): string return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(n); } +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export function planLimitMessage(code: string | undefined, fallback = 'Action not allowed.'): string { switch (code) { case 'plan_limit_clients': diff --git a/apps/web/src/pages/app/CaseDetailPage.tsx b/apps/web/src/pages/app/CaseDetailPage.tsx index 67f50d0..fac216f 100644 --- a/apps/web/src/pages/app/CaseDetailPage.tsx +++ b/apps/web/src/pages/app/CaseDetailPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { useForm } from 'react-hook-form'; -import { ArrowLeft, Plus, Receipt, Trash2 } from 'lucide-react'; +import { ArrowLeft, Download, FileText, Loader2, Plus, Receipt, Trash2, Upload } from 'lucide-react'; import { PageHeader } from '@/components/app/AppLayout'; import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; @@ -10,7 +10,8 @@ import { Badge } from '@/components/ui/Badge'; import { CaseTimeList } from '@/components/app/CaseTimeList'; import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer'; import { useInvoices, type InvoiceStatus } from '@/hooks/useInvoices'; -import { formatDate, formatMoney } from '@/lib/format'; +import { formatBytes, formatDate, formatMoney } from '@/lib/format'; +import { useDocuments, useUploadDocument, useDeleteDocument } from '@/hooks/useDocuments'; import { useCase, useDeleteCase, @@ -36,6 +37,9 @@ export default function CaseDetailPage() { const [editing, setEditing] = useState(false); const [invoiceDrawerOpen, setInvoiceDrawerOpen] = useState(false); const invoices = useInvoices(id ? { caseId: id } : undefined); + const docs = useDocuments(id); + const uploadDoc = useUploadDocument(id ?? ''); + const deleteDoc = useDeleteDocument(id ?? ''); const { register, @@ -255,11 +259,77 @@ export default function CaseDetailPage() { + { + const file = e.target.files?.[0]; + if (file) uploadDoc.mutate(file); + e.target.value = ''; + }} + disabled={uploadDoc.isPending} + /> + + {uploadDoc.isPending + ? + : } + {uploadDoc.isPending ? 'Uploading…' : 'Upload'} + + + } /> - -

Coming next.

-
+ {uploadDoc.isError && ( +
+ Upload failed: {uploadDoc.error?.message} +
+ )} + {!docs.data?.length ? ( + } + title="No documents yet" + description="Upload PDF, Word, Excel, or image files up to 50 MB." + /> + ) : ( +
    + {docs.data.map((doc) => ( +
  • +
    + +
    +

    {doc.name}

    +

    + {formatBytes(doc.sizeBytes)} · {formatDate(doc.createdAt)} +

    +
    +
    +
    + + + Download + + +
    +
  • + ))} +
+ )}
c.status === 'open') ?? []; const totalMinutes = cases.data?.items.reduce((acc, c) => acc + (c.billedMinutes ?? 0), 0) ?? 0; @@ -51,7 +53,7 @@ export default function DashboardPage() { } label="Outstanding" - value="$0" + value={formatMoney(summary.data?.outstanding ?? 0)} to="/app/invoices" />