Files
Gallus_Pub/backend/src/routes/gallery.ts
T
KenzoandClaude Opus 5 60246f3941 feat(cms): Aufraeumen von Uploads, Texte-Bearbeitung und PDF-Slot
Bilder wurden nie geloescht - weder beim Loeschen eines Datensatzes noch
beim Austauschen. Dazu waren die Textbereiche zwar im Backend vorhanden,
aber ohne Oberflaeche, und die Getraenkekarte hing hartkodiert im Markup.

Aufraeumen:
- asset.service.ts loescht ausschliesslich Dateien, die das CMS selbst
  angelegt hat (Muster <zeitstempel>-<zufall>.<ext>) und nur innerhalb der
  Upload-Ordner. Handgepflegte Assets wie event_karaoke.jpg oder Welcome.png
  bleiben unangetastet, auch wenn sie nirgends referenziert sind.
- image-refs.service.ts sammelt alle benutzten Bild-URLs, inklusive der
  Pfade aus den Textbereichen. Geloescht wird nur, was wirklich niemand
  mehr benutzt - ein von zwei Events geteiltes Bild bleibt liegen.
- Events und Gallery raeumen beim Loeschen und beim Bildwechsel mit auf.
- Der Publish entfernt zusaetzlich Verwaiste. Die Referenzliste deckt
  bewusst alle Zeilen ab, auch unveroeffentlichte, sonst verlieren die ihr
  Bild. Die Loeschungen werden mitcommittet, das Repo schrumpft also.

Texte:
- Neuer Adminbereich fuer Hero, Willkommen und Drinks ueber die schon
  vorhandenen /api/content-Endpunkte, samt Highlights-Liste und Bildern.
- Der Generator maskiert Texte jetzt. Ohne das haette ein "<" oder "&" in
  einem Feld gereicht, um den Astro-Build und damit den Deploy zu killen.

PDF:
- POST /api/pdf/drinks nimmt die Getraenkekarte entgegen, hinterlegt die
  URL in der drinks-Section und raeumt den Vorgaenger weg. Der Generator
  verlinkt sie, statt den Pfad fest im Markup zu haben.

Ausserdem:
- Vorschaubilder im Admin auf eine feste Box gezwungen. Uploads sind bis
  1600px breit und haben das Layout je nach Seitenverhaeltnis zerrissen.
- git.service.ts sichert public/pdf beim Neu-Clone mit weg, nicht nur
  public/images.
- Der Publish scheitert nicht mehr, wenn nur das Audit-Log nicht
  geschrieben werden kann - der Push ist da laengst durch.
- Upload-Logik lag dreifach kopiert vor, jetzt in upload.service.ts. Der
  Helfer prueft auch auf abgeschnittene Dateien; bisher landete bei zu
  grossen Uploads ein kaputtes Bild auf der Platte.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 11:42:59 +02:00

207 lines
6.3 KiB
TypeScript

import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { galleryImages } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { dropImageIfUnused } from '../services/image-refs.service.js';
import { saveUploadedImage } from '../services/upload.service.js';
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
try {
if (await dropImageIfUnused(url)) {
fastify.log.info(`Removed ${reason} ${url}`);
}
} catch (err) {
fastify.log.warn({ err }, `Could not remove ${reason}`);
}
}
// Fastify JSON schema for gallery image body
const galleryBodyJsonSchema = {
type: 'object',
required: ['imageUrl', 'altText', 'displayOrder'],
properties: {
imageUrl: { type: 'string', minLength: 1 },
altText: { type: 'string', minLength: 1, maxLength: 200 },
displayOrder: { type: 'integer', minimum: 0 },
isPublished: { type: 'boolean' },
},
} as const;
const galleryRoute: FastifyPluginAsync = async (fastify) => {
// PUBLIC: List published gallery images (no auth required)
fastify.get('/gallery/public', async () => {
const images = await db.select().from(galleryImages)
.where(eq(galleryImages.isPublished, true))
.orderBy(galleryImages.displayOrder);
return { images };
});
// List all gallery images - admin only
fastify.get('/gallery', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder);
return { images };
});
// Get single gallery image
fastify.get('/gallery/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
if (image.length === 0) {
return reply.code(404).send({ error: 'Image not found' });
}
return { image: image[0] };
});
// Create gallery image
fastify.post('/gallery', {
schema: {
body: galleryBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const data = request.body as any;
const [newImage] = await db.insert(galleryImages).values(data).returning();
return reply.code(201).send({ image: newImage });
});
// Upload image file (multipart)
fastify.post('/gallery/upload', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
// Expect a single file field named "file"
const file = await (request as any).file();
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
const altText = (file.fields?.altText?.value as string | undefined) || '';
const displayOrderRaw = (file.fields?.displayOrder?.value as string | undefined) || '0';
const displayOrder = Number.parseInt(displayOrderRaw) || 0;
const mime = file.mimetype as string | undefined;
if (!mime || !mime.startsWith('image/')) {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const saved = await saveUploadedImage(file, 'gallery', fastify.log);
// Store in DB (optional but useful)
const [row] = await db.insert(galleryImages).values({
imageUrl: saved.imageUrl,
altText: altText || saved.filename,
displayOrder,
isPublished: true,
}).returning();
return reply.code(201).send({ image: row });
} catch (err: any) {
if (err?.statusCode === 413) {
return reply.code(413).send({ error: err.message });
}
fastify.log.error({ err }, 'Upload failed');
return reply.code(500).send({ error: 'Failed to upload image' });
}
});
// Update gallery image
fastify.put('/gallery/:id', {
schema: {
body: galleryBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const data = request.body as any;
const [previous] = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
const [updated] = await db
.update(galleryImages)
.set(data)
.where(eq(galleryImages.id, id))
.returning();
if (!updated) {
return reply.code(404).send({ error: 'Image not found' });
}
// Ausgetauschte Datei wegraeumen
if (previous && previous.imageUrl !== updated.imageUrl) {
await dropUnusedImage(fastify, previous.imageUrl, 'replaced gallery image');
}
return { image: updated };
});
// Delete gallery image
fastify.delete('/gallery/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const [deleted] = await db
.delete(galleryImages)
.where(eq(galleryImages.id, id))
.returning();
if (!deleted) {
return reply.code(404).send({ error: 'Image not found' });
}
// Zugehoerige Bilddatei mitnehmen
await dropUnusedImage(fastify, deleted.imageUrl, 'gallery image');
return { message: 'Image deleted successfully' };
});
// Reorder gallery images
fastify.put('/gallery/reorder', {
schema: {
body: {
type: 'object',
required: ['orders'],
properties: {
orders: {
type: 'array',
items: {
type: 'object',
required: ['id', 'displayOrder'],
properties: {
id: { type: 'string' },
displayOrder: { type: 'integer', minimum: 0 },
},
},
},
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
// Update all in synchronous transaction (better-sqlite3 requirement)
db.transaction((tx: any) => {
for (const { id, displayOrder } of orders) {
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
}
});
return { message: 'Gallery images reordered successfully' };
});
};
export default galleryRoute;