feat: organizers, meetups UI, Plausible analytics, and migration tooling

- Add organizer model/API, admin and public organizer pages, meetup cards
- Refresh events/home/contact; add calendar dialog and carousel components
- Optional Plausible via NEXT_PUBLIC_PLAUSIBLE_* env vars in root layout
- Prisma migration, seed updates, baseline-and-migrate script

Made-with: Cursor
This commit is contained in:
bbe
2026-04-04 21:55:34 +02:00
parent 586b572f73
commit 78271ea110
37 changed files with 1555 additions and 301 deletions
+7 -1
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
@@ -92,6 +93,7 @@ router.get('/ics', async (_req: Request, res: Response) => {
const meetups = await prisma.meetup.findMany({
where: { date: { gte: cutoff } },
orderBy: { date: 'asc' },
include: { organizer: true },
});
const siteUrl = (process.env.FRONTEND_URL || 'https://belgianbitcoinembassy.org').replace(
@@ -129,8 +131,11 @@ router.get('/ics', async (_req: Request, res: Response) => {
lines.push(fold(`LOCATION:${escapeIcs(meetup.location)}`));
}
lines.push(fold(`URL:${eventUrl}`));
const orgName = meetup.organizer?.name || 'Belgian Bitcoin Embassy';
lines.push(
'ORGANIZER;CN=Belgian Bitcoin Embassy:mailto:info@belgianbitcoinembassy.org'
fold(
`ORGANIZER;CN=${escapeIcs(orgName)}:mailto:info@belgianbitcoinembassy.org`
)
);
// 15-minute reminder alarm
lines.push('BEGIN:VALARM');
@@ -156,6 +161,7 @@ router.get('/ics', async (_req: Request, res: Response) => {
res.send(icsBody);
} catch (err) {
console.error('Calendar ICS error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
+68 -6
View File
@@ -1,9 +1,13 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { DEFAULT_ORGANIZER_SLUG } from '../constants/organizer';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
const meetupInclude = { organizer: true } as const;
function incrementTitle(title: string): string {
const match = title.match(/^(.*#)(\d+)(.*)$/);
if (match) {
@@ -13,22 +17,42 @@ function incrementTitle(title: string): string {
return `${title} (copy)`;
}
async function resolveOrganizerIdForCreate(organizerId: unknown): Promise<string | null> {
if (typeof organizerId === 'string' && organizerId.trim()) {
return organizerId;
}
const def = await prisma.organizer.findUnique({ where: { slug: DEFAULT_ORGANIZER_SLUG } });
return def?.id ?? null;
}
router.get('/', async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
const admin = req.query.admin === 'true';
const where: any = {};
const organizerSlug = req.query.organizerSlug as string | undefined;
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (!admin) where.visibility = 'PUBLIC';
if (organizerSlug) {
const org = await prisma.organizer.findUnique({ where: { slug: organizerSlug } });
if (!org) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
where.organizerId = org.id;
}
const meetups = await prisma.meetup.findMany({
where,
orderBy: { date: 'asc' },
include: meetupInclude,
});
res.json(meetups);
} catch (err) {
console.error('List meetups error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
@@ -37,6 +61,7 @@ router.get('/:id', async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
where: { id: req.params.id as string },
include: meetupInclude,
});
if (!meetup) {
@@ -47,6 +72,7 @@ router.get('/:id', async (req: Request, res: Response) => {
res.json(meetup);
} catch (err) {
console.error('Get meetup error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
@@ -57,8 +83,19 @@ router.post(
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { title, description, date, time, location, link, status, featured, imageId, visibility } =
req.body;
const {
title,
description,
date,
time,
location,
link,
status,
featured,
imageId,
visibility,
organizerId,
} = req.body;
if (!title || !description || !date || !time || !location) {
res
@@ -67,6 +104,12 @@ router.post(
return;
}
const resolvedOrganizerId = await resolveOrganizerIdForCreate(organizerId);
if (!resolvedOrganizerId) {
res.status(500).json({ error: 'Default organizer is not configured' });
return;
}
const meetup = await prisma.meetup.create({
data: {
title,
@@ -79,7 +122,9 @@ router.post(
status: status || 'DRAFT',
featured: featured || false,
visibility: visibility || 'PUBLIC',
organizerId: resolvedOrganizerId,
},
include: meetupInclude,
});
res.status(201).json(meetup);
@@ -134,7 +179,9 @@ router.post(
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
organizerId: m.organizerId,
},
include: meetupInclude,
})
)
);
@@ -174,7 +221,9 @@ router.post(
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
organizerId: original.organizerId,
},
include: meetupInclude,
});
res.status(201).json(duplicate);
@@ -199,10 +248,21 @@ router.patch(
return;
}
const { title, description, date, time, location, link, status, featured, imageId, visibility } =
req.body;
const {
title,
description,
date,
time,
location,
link,
status,
featured,
imageId,
visibility,
organizerId,
} = req.body;
const updateData: any = {};
const updateData: Record<string, unknown> = {};
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (date !== undefined) updateData.date = date;
@@ -213,10 +273,12 @@ router.patch(
if (featured !== undefined) updateData.featured = featured;
if (imageId !== undefined) updateData.imageId = imageId;
if (visibility !== undefined) updateData.visibility = visibility;
if (organizerId !== undefined) updateData.organizerId = organizerId;
const updated = await prisma.meetup.update({
where: { id: req.params.id as string },
data: updateData,
include: meetupInclude,
});
res.json(updated);
+132
View File
@@ -0,0 +1,132 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
router.get('/', async (_req: Request, res: Response) => {
try {
const organizers = await prisma.organizer.findMany({
orderBy: { name: 'asc' },
});
res.json(organizers);
} catch (err) {
console.error('List organizers error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/by-slug/:slug', async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
where: { slug: req.params.slug as string },
});
if (!organizer) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
res.json(organizer);
} catch (err) {
console.error('Get organizer by slug error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { name, slug } = req.body;
if (!name || !slug) {
res.status(400).json({ error: 'name and slug are required' });
return;
}
const organizer = await prisma.organizer.create({
data: { name, slug },
});
res.status(201).json(organizer);
} catch (err: any) {
if (err?.code === 'P2002') {
res.status(400).json({ error: 'An organizer with this slug already exists' });
return;
}
console.error('Create organizer error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
where: { id: req.params.id as string },
});
if (!organizer) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
const { name, slug } = req.body;
const updateData: { name?: string; slug?: string } = {};
if (name !== undefined) updateData.name = name;
if (slug !== undefined) updateData.slug = slug;
const updated = await prisma.organizer.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err: any) {
if (err?.code === 'P2002') {
res.status(400).json({ error: 'An organizer with this slug already exists' });
return;
}
console.error('Update organizer error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
where: { id: req.params.id as string },
include: { _count: { select: { meetups: true } } },
});
if (!organizer) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
if (organizer._count.meetups > 0) {
res.status(400).json({
error: `Cannot delete organizer: ${organizer._count.meetups} event(s) still reference it`,
});
return;
}
await prisma.organizer.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete organizer error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;