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
This commit is contained in:
2026-03-13 05:08:57 +00:00
parent 3ad71d3afc
commit 2b5910cc75
4 changed files with 172 additions and 2 deletions

View File

@@ -29,11 +29,28 @@ export async function initDB(): Promise<void> {
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<Pool["connect"]> extends Promise<infer T> ? T : never): Promise<void> {
// 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;
}

View File

@@ -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) => {

View File

@@ -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<string, unknown> }>(
"SELECT id, name, slug, settings FROM organizations WHERE id = $1",
const org = await queryOne<{ id: string; name: string; slug: string; settings: Record<string, unknown>; logo_url: string | null }>(
"SELECT id, name, slug, settings, logo_url FROM organizations WHERE id = $1",
[orgId]
);

150
src/routes/uploads.ts Normal file
View File

@@ -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<string, string> = {
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);
}
});