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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
18
backend/scripts/baseline-and-migrate.sh
Executable file
18
backend/scripts/baseline-and-migrate.sh
Executable 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
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
backend/src/api/organizers.ts
Normal file
132
backend/src/api/organizers.ts
Normal 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;
|
||||
2
backend/src/constants/organizer.ts
Normal file
2
backend/src/constants/organizer.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Slug for the default organizer row (seed + migration). */
|
||||
export const DEFAULT_ORGANIZER_SLUG = 'belgian-bitcoin-embassy';
|
||||
@@ -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);
|
||||
|
||||
36
backend/src/lib/prismaMigrationHint.ts
Normal file
36
backend/src/lib/prismaMigrationHint.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user