feat: member portal Sprint 4 — Events + RSVP

- Event + EventRsvp models + migration (RsvpStatus enum: GOING/MAYBE/NOT_GOING)
- EventsModule: admin CRUD (create/update/delete/publish) + RSVP list
- GET /my/events + POST /my/events/:id/rsvp in MyController/MyService
- Admin BFF routes: GET/POST /api/admin/events, PATCH/DELETE /api/admin/events/[id]
- Member BFF routes: GET /api/my/events, POST /api/my/events/[id]/rsvp
- /my/events page: upcoming/past split, RSVP button (client component, optimistic)
- Register DigestModule, OrgModule, ThreadsModule, EventsModule in AppModule
- Add EVENT_CREATED/DELETED/UPDATED and DIGEST_SENT to AuditAction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:20:11 +05:30
parent f7922454ca
commit 0ffdc362b5
16 changed files with 584 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
import { RsvpButton } from './RsvpButton';
import Link from 'next/link';
type RsvpStatus = 'GOING' | 'NOT_GOING' | 'MAYBE';
interface EventItem {
id: string;
title: string;
description: string | null;
location: string | null;
startsAt: string;
endsAt: string | null;
rsvpCount: number;
myRsvp: RsvpStatus | null;
}
async function fetchEvents(token: string): Promise<EventItem[]> {
const res = await fetch(`${getApiBaseUrl()}/my/events`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return [];
return (await res.json()) as EventItem[];
}
function formatEventDate(startsAt: string, endsAt: string | null): string {
const start = new Date(startsAt);
const dateStr = start.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
const timeStr = start.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' });
if (!endsAt) return `${dateStr} · ${timeStr}`;
const end = new Date(endsAt);
const endTime = end.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' });
return `${dateStr} · ${timeStr} ${endTime}`;
}
const RSVP_BADGE: Record<RsvpStatus, { label: string; cls: string }> = {
GOING: { label: 'Going', cls: 'bg-green-100 text-green-700' },
MAYBE: { label: 'Maybe', cls: 'bg-yellow-100 text-yellow-700' },
NOT_GOING: { label: 'Not going', cls: 'bg-gray-100 text-gray-500' },
};
export default async function EventsPage() {
const token = await getMemberToken();
if (!token) return null;
const events = await fetchEvents(token);
const upcoming = events.filter((e) => new Date(e.startsAt) >= new Date());
const past = events.filter((e) => new Date(e.startsAt) < new Date());
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-900">Events</h1>
<Link href="/my" className="text-sm text-indigo-600 hover:underline"> Dashboard</Link>
</div>
{events.length === 0 && (
<div className="text-center py-16 text-gray-400">
<p className="text-sm">No events scheduled.</p>
<p className="text-xs mt-1">Your chapter admin will post events here.</p>
</div>
)}
{upcoming.length > 0 && (
<section>
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Upcoming</h2>
<div className="space-y-4">
{upcoming.map((e) => (
<div key={e.id} className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900">{e.title}</p>
<p className="text-xs text-indigo-600 mt-0.5">{formatEventDate(e.startsAt, e.endsAt)}</p>
{e.location && (
<p className="text-xs text-gray-400 mt-0.5">{e.location}</p>
)}
</div>
{e.myRsvp && (
<span className={`shrink-0 text-xs rounded-full px-2.5 py-1 font-medium ${RSVP_BADGE[e.myRsvp].cls}`}>
{RSVP_BADGE[e.myRsvp].label}
</span>
)}
</div>
{e.description && (
<p className="text-sm text-gray-600 mt-2 leading-relaxed">{e.description}</p>
)}
<div className="flex items-center justify-between mt-3">
<span className="text-xs text-gray-400">{e.rsvpCount} {e.rsvpCount === 1 ? 'response' : 'responses'}</span>
</div>
<RsvpButton eventId={e.id} initial={e.myRsvp} />
</div>
))}
</div>
</section>
)}
{past.length > 0 && (
<section>
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Past</h2>
<div className="space-y-3">
{past.map((e) => (
<div key={e.id} className="bg-white rounded-xl border border-gray-100 p-4 opacity-60">
<p className="text-sm font-medium text-gray-700">{e.title}</p>
<p className="text-xs text-gray-400 mt-0.5">{formatEventDate(e.startsAt, e.endsAt)}</p>
{e.myRsvp && (
<span className={`inline-block mt-1 text-xs rounded-full px-2 py-0.5 ${RSVP_BADGE[e.myRsvp].cls}`}>
{RSVP_BADGE[e.myRsvp].label}
</span>
)}
</div>
))}
</div>
</section>
)}
</div>
);
}