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
@@ -0,0 +1,28 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await request.json();
const res = await apiFetch(`/admin/events/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/events/${id}`, { method: 'DELETE' });
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
+20
View File
@@ -0,0 +1,20 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/admin/events');
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function POST(request: Request): Promise<Response> {
const body = await request.json();
const res = await apiFetch('/admin/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
@@ -0,0 +1,18 @@
import { jsonResponse, memberApiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await request.json();
const res = await memberApiFetch(`/my/events/${id}/rsvp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/events');
const body = await res.json();
return jsonResponse(body, res.status);
}
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { useState } from 'react';
type RsvpStatus = 'GOING' | 'NOT_GOING' | 'MAYBE';
const LABELS: Record<RsvpStatus, string> = {
GOING: 'Going',
MAYBE: 'Maybe',
NOT_GOING: 'Not going',
};
const STYLES: Record<RsvpStatus, string> = {
GOING: 'bg-green-600 text-white hover:bg-green-700',
MAYBE: 'bg-yellow-500 text-white hover:bg-yellow-600',
NOT_GOING: 'bg-gray-200 text-gray-700 hover:bg-gray-300',
};
interface Props {
eventId: string;
initial: RsvpStatus | null;
}
export function RsvpButton({ eventId, initial }: Props) {
const [current, setCurrent] = useState<RsvpStatus | null>(initial);
const [loading, setLoading] = useState(false);
const rsvp = async (status: RsvpStatus) => {
if (loading) return;
setLoading(true);
try {
const res = await fetch(`/api/my/events/${eventId}/rsvp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
if (res.ok) setCurrent(status);
} finally {
setLoading(false);
}
};
return (
<div className="flex gap-2 mt-3">
{(['GOING', 'MAYBE', 'NOT_GOING'] as RsvpStatus[]).map((s) => (
<button
key={s}
type="button"
disabled={loading}
onClick={() => rsvp(s)}
className={`text-xs rounded-full px-3 py-1.5 font-medium transition-colors disabled:opacity-50 border ${
current === s
? STYLES[s]
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{LABELS[s]}
</button>
))}
</div>
);
}
+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>
);
}
+1
View File
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
const NAV = [
{ href: '/my', label: 'Dashboard' },
{ href: '/my/digest', label: 'Digest' },
{ href: '/my/events', label: 'Events' },
{ href: '/my/groups', label: 'My Groups' },
{ href: '/my/settings', label: 'Settings' },
];