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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user