From 2b5910cc75f1b99a60543650600438f26c74fc5d Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 13 Mar 2026 05:08:57 +0000 Subject: [PATCH] feat: Add organization logo upload - Add uploads router with logo upload/delete/serve endpoints - Add logo_url column migration for organizations table - Update /organizations/current to include logo_url - Max 5MB, supports JPG/PNG/WebP/SVG --- src/db/postgres.ts | 17 ++++ src/main.ts | 3 + src/routes/organizations.ts | 4 +- src/routes/uploads.ts | 150 ++++++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 src/routes/uploads.ts diff --git a/src/db/postgres.ts b/src/db/postgres.ts index a86ddce..7082050 100644 --- a/src/db/postgres.ts +++ b/src/db/postgres.ts @@ -29,11 +29,28 @@ export async function initDB(): Promise { try { const result = await client.queryObject`SELECT NOW()`; console.log("✅ Database connected:", result.rows[0]); + + // Run migrations + await runMigrations(client); } finally { client.release(); } } +async function runMigrations(client: ReturnType extends Promise ? T : never): Promise { + // Add logo_url column to organizations if not exists + try { + await client.queryObject` + ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS logo_url TEXT + `; + console.log("✅ Migration: logo_url column checked"); + } catch (e) { + // Column might already exist, that's fine + console.log("ℹ️ Migration note:", e instanceof Error ? e.message : String(e)); + } +} + export function getPool(): Pool { return pool; } diff --git a/src/main.ts b/src/main.ts index 1716373..6b14b5f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { vehiclesRouter } from "./routes/vehicles.ts"; import { documentsRouter } from "./routes/documents.ts"; import { customersRouter } from "./routes/customers.ts"; import { billingRouter } from "./routes/billing.ts"; +import { uploadsRouter } from "./routes/uploads.ts"; import { errorHandler } from "./middleware/error.ts"; import { requestLogger } from "./middleware/logger.ts"; import { initDB } from "./db/postgres.ts"; @@ -77,6 +78,8 @@ app.use(customersRouter.routes()); app.use(customersRouter.allowedMethods()); app.use(billingRouter.routes()); app.use(billingRouter.allowedMethods()); +app.use(uploadsRouter.routes()); +app.use(uploadsRouter.allowedMethods()); // Health check app.use((ctx) => { diff --git a/src/routes/organizations.ts b/src/routes/organizations.ts index 65b8c6c..a2f9ee2 100644 --- a/src/routes/organizations.ts +++ b/src/routes/organizations.ts @@ -110,8 +110,8 @@ organizationsRouter.post("/register", async (ctx) => { organizationsRouter.get("/current", authMiddleware, async (ctx) => { const { org_id: orgId } = ctx.state.auth.user; - const org = await queryOne<{ id: string; name: string; slug: string; settings: Record }>( - "SELECT id, name, slug, settings FROM organizations WHERE id = $1", + const org = await queryOne<{ id: string; name: string; slug: string; settings: Record; logo_url: string | null }>( + "SELECT id, name, slug, settings, logo_url FROM organizations WHERE id = $1", [orgId] ); diff --git a/src/routes/uploads.ts b/src/routes/uploads.ts new file mode 100644 index 0000000..1324103 --- /dev/null +++ b/src/routes/uploads.ts @@ -0,0 +1,150 @@ +import { Router } from "@oak/oak"; +import { queryOne, execute } from "../db/postgres.ts"; +import { AppError } from "../middleware/error.ts"; +import { authMiddleware } from "../middleware/auth.ts"; +import { ensureDir } from "https://deno.land/std@0.208.0/fs/ensure_dir.ts"; + +export const uploadsRouter = new Router({ prefix: "/api/uploads" }); + +const UPLOAD_DIR = "/app/uploads"; +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/svg+xml"]; + +// Ensure upload directory exists +await ensureDir(UPLOAD_DIR); +await ensureDir(`${UPLOAD_DIR}/logos`); + +// Upload organization logo (chef only) +uploadsRouter.post("/logo", authMiddleware, async (ctx) => { + const { org_id: orgId, role } = ctx.state.auth.user; + + if (role !== "chef") { + throw new AppError("Nur der Chef kann das Logo ändern", 403); + } + + const contentType = ctx.request.headers.get("content-type") || ""; + + if (!contentType.includes("multipart/form-data")) { + throw new AppError("Multipart form data required", 400); + } + + const body = ctx.request.body; + const form = await body.formData(); + const file = form.get("logo"); + + if (!file || !(file instanceof File)) { + throw new AppError("Keine Datei hochgeladen", 400); + } + + // Validate file type + if (!ALLOWED_TYPES.includes(file.type)) { + throw new AppError("Nur JPG, PNG, WebP oder SVG erlaubt", 400); + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + throw new AppError("Datei zu groß (max 5MB)", 400); + } + + // Generate unique filename + const ext = file.name.split(".").pop() || "png"; + const filename = `${orgId}-${Date.now()}.${ext}`; + const filepath = `${UPLOAD_DIR}/logos/${filename}`; + + // Delete old logo if exists + const oldOrg = await queryOne<{ logo_url: string | null }>( + "SELECT logo_url FROM organizations WHERE id = $1", + [orgId] + ); + + if (oldOrg?.logo_url) { + const oldFilename = oldOrg.logo_url.split("/").pop(); + if (oldFilename) { + try { + await Deno.remove(`${UPLOAD_DIR}/logos/${oldFilename}`); + } catch { + // Ignore if file doesn't exist + } + } + } + + // Save file + const arrayBuffer = await file.arrayBuffer(); + await Deno.writeFile(filepath, new Uint8Array(arrayBuffer)); + + // Update organization + const logoUrl = `/api/uploads/logos/${filename}`; + await execute( + "UPDATE organizations SET logo_url = $1 WHERE id = $2", + [logoUrl, orgId] + ); + + ctx.response.body = { + message: "Logo hochgeladen", + logo_url: logoUrl, + }; +}); + +// Delete organization logo (chef only) +uploadsRouter.delete("/logo", authMiddleware, async (ctx) => { + const { org_id: orgId, role } = ctx.state.auth.user; + + if (role !== "chef") { + throw new AppError("Nur der Chef kann das Logo löschen", 403); + } + + const org = await queryOne<{ logo_url: string | null }>( + "SELECT logo_url FROM organizations WHERE id = $1", + [orgId] + ); + + if (org?.logo_url) { + const filename = org.logo_url.split("/").pop(); + if (filename) { + try { + await Deno.remove(`${UPLOAD_DIR}/logos/${filename}`); + } catch { + // Ignore if file doesn't exist + } + } + + await execute( + "UPDATE organizations SET logo_url = NULL WHERE id = $1", + [orgId] + ); + } + + ctx.response.body = { message: "Logo gelöscht" }; +}); + +// Serve uploaded logos (public) +uploadsRouter.get("/logos/:filename", async (ctx) => { + const filename = ctx.params.filename; + + // Sanitize filename + if (filename.includes("..") || filename.includes("/")) { + throw new AppError("Ungültiger Dateiname", 400); + } + + const filepath = `${UPLOAD_DIR}/logos/${filename}`; + + try { + const file = await Deno.readFile(filepath); + + // Determine content type + const ext = filename.split(".").pop()?.toLowerCase(); + const contentTypes: Record = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + webp: "image/webp", + svg: "image/svg+xml", + }; + + ctx.response.headers.set("Content-Type", contentTypes[ext || ""] || "application/octet-stream"); + ctx.response.headers.set("Cache-Control", "public, max-age=86400"); + ctx.response.body = file; + } catch { + throw new AppError("Logo nicht gefunden", 404); + } +});