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 {
|
try {
|
||||||
const result = await client.queryObject`SELECT NOW()`;
|
const result = await client.queryObject`SELECT NOW()`;
|
||||||
console.log("✅ Database connected:", result.rows[0]);
|
console.log("✅ Database connected:", result.rows[0]);
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
await runMigrations(client);
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
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 {
|
export function getPool(): Pool {
|
||||||
return pool;
|
return pool;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { vehiclesRouter } from "./routes/vehicles.ts";
|
|||||||
import { documentsRouter } from "./routes/documents.ts";
|
import { documentsRouter } from "./routes/documents.ts";
|
||||||
import { customersRouter } from "./routes/customers.ts";
|
import { customersRouter } from "./routes/customers.ts";
|
||||||
import { billingRouter } from "./routes/billing.ts";
|
import { billingRouter } from "./routes/billing.ts";
|
||||||
|
import { uploadsRouter } from "./routes/uploads.ts";
|
||||||
import { errorHandler } from "./middleware/error.ts";
|
import { errorHandler } from "./middleware/error.ts";
|
||||||
import { requestLogger } from "./middleware/logger.ts";
|
import { requestLogger } from "./middleware/logger.ts";
|
||||||
import { initDB } from "./db/postgres.ts";
|
import { initDB } from "./db/postgres.ts";
|
||||||
@@ -77,6 +78,8 @@ app.use(customersRouter.routes());
|
|||||||
app.use(customersRouter.allowedMethods());
|
app.use(customersRouter.allowedMethods());
|
||||||
app.use(billingRouter.routes());
|
app.use(billingRouter.routes());
|
||||||
app.use(billingRouter.allowedMethods());
|
app.use(billingRouter.allowedMethods());
|
||||||
|
app.use(uploadsRouter.routes());
|
||||||
|
app.use(uploadsRouter.allowedMethods());
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.use((ctx) => {
|
app.use((ctx) => {
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ organizationsRouter.post("/register", async (ctx) => {
|
|||||||
organizationsRouter.get("/current", authMiddleware, async (ctx) => {
|
organizationsRouter.get("/current", authMiddleware, async (ctx) => {
|
||||||
const { org_id: orgId } = ctx.state.auth.user;
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
|
||||||
const org = await queryOne<{ id: string; name: string; slug: string; settings: Record<string, unknown> }>(
|
const org = await queryOne<{ id: string; name: string; slug: string; settings: Record<string, unknown>; logo_url: string | null }>(
|
||||||
"SELECT id, name, slug, settings FROM organizations WHERE id = $1",
|
"SELECT id, name, slug, settings, logo_url FROM organizations WHERE id = $1",
|
||||||
[orgId]
|
[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