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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
150
src/routes/uploads.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user