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

View File

@@ -6,6 +6,8 @@
"build": "tsc",
"start": "node dist/index.js",
"db:push": "dotenv -e ../.env -e .env -- prisma db push",
"db:migrate": "dotenv -e ../.env -e .env -- prisma migrate deploy",
"db:baseline-and-migrate": "bash scripts/baseline-and-migrate.sh",
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy"

View File

@@ -0,0 +1,47 @@
-- CreateTable
CREATE TABLE "Organizer" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE UNIQUE INDEX "Organizer_slug_key" ON "Organizer"("slug");
INSERT INTO "Organizer" ("id", "name", "slug", "createdAt", "updatedAt")
VALUES (
'00000000-0000-4000-8000-000000000001',
'Belgian Bitcoin Embassy',
'belgian-bitcoin-embassy',
datetime('now'),
datetime('now')
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Meetup" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"date" TEXT NOT NULL,
"time" TEXT NOT NULL,
"location" TEXT NOT NULL,
"link" TEXT,
"imageId" TEXT,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"featured" BOOLEAN NOT NULL DEFAULT false,
"visibility" TEXT NOT NULL DEFAULT 'PUBLIC',
"organizerId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Meetup_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "Organizer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Meetup" ("createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "organizerId", "status", "time", "title", "updatedAt", "visibility")
SELECT "createdAt", "date", "description", "featured", "id", "imageId", "link", "location", '00000000-0000-4000-8000-000000000001', "status", "time", "title", "updatedAt", "visibility" FROM "Meetup";
DROP TABLE "Meetup";
ALTER TABLE "new_Meetup" RENAME TO "Meetup";
CREATE INDEX "Meetup_organizerId_idx" ON "Meetup"("organizerId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -17,8 +17,17 @@ model User {
updatedAt DateTime @updatedAt
}
model Organizer {
id String @id @default(uuid())
name String
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
meetups Meetup[]
}
model Meetup {
id String @id @default(uuid())
id String @id @default(uuid())
title String
description String
date String
@@ -26,11 +35,13 @@ model Meetup {
location String
link String?
imageId String?
status String @default("DRAFT") // DRAFT, PUBLISHED, CANCELLED (Upcoming/Past derived from date)
featured Boolean @default(false)
visibility String @default("PUBLIC") // PUBLIC, HIDDEN
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status String @default("DRAFT") // DRAFT, PUBLISHED, CANCELLED (Upcoming/Past derived from date)
featured Boolean @default(false)
visibility String @default("PUBLIC") // PUBLIC, HIDDEN
organizerId String
organizer Organizer @relation(fields: [organizerId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Media {

View File

@@ -56,6 +56,16 @@ async function main() {
});
}
const defaultOrganizer = await prisma.organizer.upsert({
where: { slug: 'belgian-bitcoin-embassy' },
update: {},
create: {
id: '00000000-0000-4000-8000-000000000001',
name: 'Belgian Bitcoin Embassy',
slug: 'belgian-bitcoin-embassy',
},
});
const existingMeetup = await prisma.meetup.findFirst({
where: { title: 'Monthly Bitcoin Meetup' },
});
@@ -70,8 +80,9 @@ async function main() {
time: '19:00',
location: 'Brussels, Belgium',
link: 'https://meetup.com/example',
status: 'UPCOMING',
status: 'PUBLISHED',
featured: true,
organizerId: defaultOrganizer.id,
},
});
}

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Use when `npm run db:migrate` fails with P3005 (DB has tables but no migrate history, e.g. after db push).
# Marks historical migrations as already applied, then runs `migrate deploy` to apply pending ones (e.g. organizer).
set -euo pipefail
cd "$(dirname "$0")/.."
for migration_name in \
20260331051150_add_user_username \
20260331053518_add_meetup_visibility \
20260331061812_add_user_username
do
echo "Marking as applied: $migration_name"
# Ignore failures when already recorded or already baselined
dotenv -e ../.env -e .env -- prisma migrate resolve --applied "$migration_name" || true
done
echo "Applying any pending migrations..."
dotenv -e ../.env -e .env -- prisma migrate deploy

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' });
}
});

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);

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;

View File

@@ -0,0 +1,2 @@
/** Slug for the default organizer row (seed + migration). */
export const DEFAULT_ORGANIZER_SLUG = 'belgian-bitcoin-embassy';

View File

@@ -10,6 +10,7 @@ import morgan from 'morgan';
import authRouter from './api/auth';
import postsRouter from './api/posts';
import meetupsRouter from './api/meetups';
import organizersRouter from './api/organizers';
import moderationRouter from './api/moderation';
import usersRouter from './api/users';
import categoriesRouter from './api/categories';
@@ -39,6 +40,7 @@ app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api/posts', postsRouter);
app.use('/api/meetups', meetupsRouter);
app.use('/api/organizers', organizersRouter);
app.use('/api/moderation', moderationRouter);
app.use('/api/users', usersRouter);
app.use('/api/categories', categoriesRouter);

View File

@@ -0,0 +1,36 @@
import type { Response } from 'express';
/**
* When the DB was never migrated for organizers (or db push failed), Prisma throws.
* Return a clear JSON error so operators know to run `prisma migrate deploy`, not `db push`.
*/
export function respondIfOrganizerMigrationNeeded(err: unknown, res: Response): boolean {
const e = err as {
code?: string;
meta?: { modelName?: string; table?: string; column?: string };
message?: string;
};
const msg = String(e?.message ?? '');
if (e?.code === 'P2021') {
const model = e.meta?.modelName ?? '';
const table = e.meta?.table ?? '';
if (model === 'Organizer' || table.includes('Organizer')) {
res.status(503).json({
error:
'Database is missing the Organizer table. On the server run: cd backend && npm run migrate:deploy (use Prisma migrate, not db push).',
});
return true;
}
}
if (e?.code === 'P2022' && (msg.includes('organizerId') || e.meta?.column === 'organizerId')) {
res.status(503).json({
error:
'Database is missing meetup organizer columns. On the server run: cd backend && npm run migrate:deploy (do not use prisma db push for this upgrade).',
});
return true;
}
return false;
}