Add CMS features with admin interface and OAuth authentication integration

- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
This commit is contained in:
2025-12-08 16:00:40 +01:00
parent 22494084ce
commit a28d43db45
16 changed files with 603 additions and 186 deletions

View File

@ -1,121 +1,87 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { events } from '../db/schema.js';
import { eq } from 'drizzle-orm';
const eventSchema = z.object({
title: z.string().min(1).max(200),
date: z.string().min(1).max(100),
description: z.string().min(1),
imageUrl: z.string().url(),
displayOrder: z.number().int().min(0),
isPublished: z.boolean().optional().default(true),
});
// Fastify JSON schema for event body
const eventBodyJsonSchema = {
type: 'object',
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'],
properties: {
title: { type: 'string', minLength: 1, maxLength: 200 },
date: { type: 'string', minLength: 1, maxLength: 100 },
description: { type: 'string', minLength: 1 },
imageUrl: { type: 'string', minLength: 1 },
displayOrder: { type: 'integer', minimum: 0 },
isPublished: { type: 'boolean' },
},
} as const;
const reorderBodyJsonSchema = {
type: 'object',
required: ['orders'],
properties: {
orders: {
type: 'array',
items: {
type: 'object',
required: ['id', 'displayOrder'],
properties: {
id: { type: 'string' },
displayOrder: { type: 'integer', minimum: 0 },
},
},
},
},
} as const;
const eventsRoute: FastifyPluginAsync = async (fastify) => {
// List all events
fastify.get('/events', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const allEvents = await db.select().from(events).orderBy(events.displayOrder);
return { events: allEvents };
// List all events (by displayOrder)
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
const all = await db.select().from(events).orderBy(events.displayOrder);
return { events: all };
});
// Get single event
fastify.get('/events/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string };
const event = await db.select().from(events).where(eq(events.id, id)).limit(1);
if (event.length === 0) {
return reply.code(404).send({ error: 'Event not found' });
}
return { event: event[0] };
const rows = await db.select().from(events).where(eq(events.id, id)).limit(1);
if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' });
return { event: rows[0] };
});
// Create event
fastify.post('/events', {
schema: {
body: eventSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const data = request.body as z.infer<typeof eventSchema>;
const [newEvent] = await db.insert(events).values(data).returning();
return reply.code(201).send({ event: newEvent });
fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
const data = request.body as any;
const [row] = await db.insert(events).values(data).returning();
return reply.code(201).send({ event: row });
});
// Update event
fastify.put('/events/:id', {
schema: {
body: eventSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string };
const data = request.body as z.infer<typeof eventSchema>;
const [updated] = await db
.update(events)
.set({ ...data, updatedAt: new Date() })
.where(eq(events.id, id))
.returning();
if (!updated) {
return reply.code(404).send({ error: 'Event not found' });
}
return { event: updated };
const data = request.body as any;
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
if (!row) return reply.code(404).send({ error: 'Event not found' });
return { event: row };
});
// Delete event
fastify.delete('/events/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string };
const [deleted] = await db
.delete(events)
.where(eq(events.id, id))
.returning();
if (!deleted) {
return reply.code(404).send({ error: 'Event not found' });
}
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
if (!row) return reply.code(404).send({ error: 'Event not found' });
return { message: 'Event deleted successfully' };
});
// Reorder events
fastify.put('/events/reorder', {
schema: {
body: z.object({
orders: z.array(z.object({
id: z.string().uuid(),
displayOrder: z.number().int().min(0),
})),
}),
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
// Reorder events (synchronous transaction for better-sqlite3)
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
// Update all in transaction
await db.transaction(async (tx) => {
db.transaction((tx: any) => {
for (const { id, displayOrder } of orders) {
await tx
.update(events)
.set({ displayOrder })
.where(eq(events.id, id));
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
}
});
return { message: 'Events reordered successfully' };
});
};