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,36 @@
CREATE TYPE "RsvpStatus" AS ENUM ('GOING', 'NOT_GOING', 'MAYBE');
CREATE TABLE "Event" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"location" TEXT,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3),
"createdBy" TEXT NOT NULL,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "EventRsvp" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "RsvpStatus" NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventRsvp_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Event_tenantId_startsAt_idx" ON "Event"("tenantId", "startsAt");
CREATE INDEX "Event_tenantId_isPublished_idx" ON "Event"("tenantId", "isPublished");
CREATE UNIQUE INDEX "EventRsvp_eventId_userId_key" ON "EventRsvp"("eventId", "userId");
CREATE INDEX "EventRsvp_userId_idx" ON "EventRsvp"("userId");
ALTER TABLE "Event" ADD CONSTRAINT "Event_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "EventRsvp" ADD CONSTRAINT "EventRsvp_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "EventRsvp" ADD CONSTRAINT "EventRsvp_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+47
View File
@@ -90,6 +90,7 @@ model Tenant {
groupAccesses GroupAccess[]
digestConfig DigestConfig?
digests Digest[]
events Event[]
}
enum AdminRole {
@@ -387,6 +388,7 @@ model TowerUser {
optOuts MemberOptOut[]
sessions TowerSession[]
messages Message[] @relation("senderTowerUser")
rsvps EventRsvp[]
@@unique([tenantId, phoneHash])
@@index([phoneHash])
@@ -528,3 +530,48 @@ model Digest {
@@unique([tenantId, digestDate])
@@index([tenantId])
}
// ============================================================================
// Events + RSVP
// ============================================================================
enum RsvpStatus {
GOING
NOT_GOING
MAYBE
}
model Event {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
title String
description String?
location String?
startsAt DateTime
endsAt DateTime?
createdBy String
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rsvps EventRsvp[]
@@index([tenantId, startsAt])
@@index([tenantId, isPublished])
}
model EventRsvp {
id String @id @default(cuid())
eventId String
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
userId String
user TowerUser @relation(fields: [userId], references: [id])
status RsvpStatus
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([eventId, userId])
@@index([userId])
}
+8
View File
@@ -14,6 +14,10 @@ import { MessagesModule } from './modules/messages/messages.module';
import { RulesModule } from './modules/rules/rules.module';
import { SuperAdminModule } from './modules/super-admin/super-admin.module';
import { TenantModule } from './modules/tenant/tenant.module';
import { DigestModule } from './modules/digest/digest.module';
import { OrgModule } from './modules/org/org.module';
import { ThreadsModule } from './modules/threads/threads.module';
import { EventsModule } from './modules/events/events.module';
@Module({
imports: [
@@ -32,6 +36,10 @@ import { TenantModule } from './modules/tenant/tenant.module';
RulesModule,
SuperAdminModule,
TenantModule,
DigestModule,
OrgModule,
ThreadsModule,
EventsModule,
],
})
export class AppModule {}
@@ -37,6 +37,10 @@ export const AuditAction = {
MEMBER_DELETED: 'MEMBER_DELETED',
OTP_REQUESTED: 'OTP_REQUESTED',
OTP_VERIFIED: 'OTP_VERIFIED',
EVENT_CREATED: 'EVENT_CREATED',
EVENT_UPDATED: 'EVENT_UPDATED',
EVENT_DELETED: 'EVENT_DELETED',
DIGEST_SENT: 'DIGEST_SENT',
} as const;
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
@@ -0,0 +1,60 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { EventsService } from './events.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
@Controller('admin/events')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class EventsController {
constructor(private readonly service: EventsService) {}
@Get()
list(@CurrentTenantContext() ctx: TenantContext) {
return this.service.list(ctx.tenantId, true);
}
@Post()
create(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: {
title: string;
description?: string;
location?: string;
startsAt: string;
endsAt?: string;
isPublished?: boolean;
},
) {
return this.service.create(ctx.tenantId, ctx.adminId!, body);
}
@Patch(':id')
update(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: {
title?: string;
description?: string;
location?: string;
startsAt?: string;
endsAt?: string;
isPublished?: boolean;
},
) {
return this.service.update(ctx.tenantId, ctx.adminId!, id, body);
}
@Delete(':id')
remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.remove(ctx.tenantId, ctx.adminId!, id);
}
@Get(':id/rsvps')
rsvps(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.getRsvps(ctx.tenantId, id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [EventsController],
providers: [EventsService],
})
export class EventsModule {}
@@ -0,0 +1,108 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
@Injectable()
export class EventsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async list(tenantId: string, includeUnpublished = false) {
return this.prisma.event.findMany({
where: {
tenantId,
...(includeUnpublished ? {} : { isPublished: true }),
},
orderBy: { startsAt: 'asc' },
include: { _count: { select: { rsvps: true } } },
});
}
async create(tenantId: string, adminId: string, dto: {
title: string;
description?: string;
location?: string;
startsAt: string;
endsAt?: string;
isPublished?: boolean;
}) {
const event = await this.prisma.event.create({
data: {
tenantId,
title: dto.title,
description: dto.description ?? null,
location: dto.location ?? null,
startsAt: new Date(dto.startsAt),
endsAt: dto.endsAt ? new Date(dto.endsAt) : null,
createdBy: adminId,
isPublished: dto.isPublished ?? false,
},
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.EVENT_CREATED,
resourceType: 'Event',
resourceId: event.id,
payload: { title: event.title, isPublished: event.isPublished },
});
return event;
}
async update(tenantId: string, adminId: string, eventId: string, dto: {
title?: string;
description?: string;
location?: string;
startsAt?: string;
endsAt?: string;
isPublished?: boolean;
}) {
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
if (!event) throw new NotFoundException('Event not found');
return this.prisma.event.update({
where: { id: eventId },
data: {
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.location !== undefined && { location: dto.location }),
...(dto.startsAt !== undefined && { startsAt: new Date(dto.startsAt) }),
...(dto.endsAt !== undefined && { endsAt: new Date(dto.endsAt) }),
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
},
});
}
async remove(tenantId: string, adminId: string, eventId: string) {
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
if (!event) throw new NotFoundException('Event not found');
await this.prisma.event.delete({ where: { id: eventId } });
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.EVENT_DELETED,
resourceType: 'Event',
resourceId: eventId,
payload: { deleted: true },
});
return { ok: true };
}
async getRsvps(tenantId: string, eventId: string) {
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
if (!event) throw new NotFoundException('Event not found');
return this.prisma.eventRsvp.findMany({
where: { eventId },
include: { user: { select: { id: true, displayName: true, jid: true } } },
orderBy: { createdAt: 'asc' },
});
}
}
+14
View File
@@ -34,6 +34,20 @@ export class MyController {
return this.service.getDigests(member.tenantId);
}
@Get('events')
listEvents(@CurrentMember() member: MemberJwtPayload) {
return this.service.listEvents(member.sub, member.tenantId);
}
@Post('events/:id/rsvp')
rsvp(
@CurrentMember() member: MemberJwtPayload,
@Param('id') eventId: string,
@Body() body: { status: 'GOING' | 'NOT_GOING' | 'MAYBE'; note?: string },
) {
return this.service.upsertRsvp(member.sub, member.tenantId, eventId, body.status, body.note);
}
@Get('profile')
profile(@CurrentMember() member: MemberJwtPayload) {
return this.service.getProfile(member.sub, member.tenantId);
+40
View File
@@ -26,6 +26,46 @@ export class MyService {
};
}
async listEvents(userId: string, tenantId: string) {
const now = new Date();
const events = await this.prisma.event.findMany({
where: { tenantId, isPublished: true, startsAt: { gte: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) } },
orderBy: { startsAt: 'asc' },
take: 20,
include: {
rsvps: {
where: { userId },
select: { status: true },
},
_count: { select: { rsvps: true } },
},
});
return events.map((e) => ({
id: e.id,
title: e.title,
description: e.description,
location: e.location,
startsAt: e.startsAt.toISOString(),
endsAt: e.endsAt?.toISOString() ?? null,
rsvpCount: e._count.rsvps,
myRsvp: e.rsvps[0]?.status ?? null,
}));
}
async upsertRsvp(userId: string, tenantId: string, eventId: string, status: 'GOING' | 'NOT_GOING' | 'MAYBE', note?: string) {
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId, isPublished: true } });
if (!event) throw new NotFoundException('Event not found');
const rsvp = await this.prisma.eventRsvp.upsert({
where: { eventId_userId: { eventId, userId } },
create: { eventId, userId, status, note: note ?? null },
update: { status, note: note ?? null },
});
return { ok: true, rsvpId: rsvp.id, status: rsvp.status };
}
async updateProfile(userId: string, tenantId: string, body: {
displayName?: string;
hometown?: string;
@@ -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' },
];