diff --git a/apps/api/prisma/migrations/20260617210000_add_events_rsvp/migration.sql b/apps/api/prisma/migrations/20260617210000_add_events_rsvp/migration.sql new file mode 100644 index 0000000..82487c8 --- /dev/null +++ b/apps/api/prisma/migrations/20260617210000_add_events_rsvp/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5874b95..b76e343 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3b78eb3..389c978 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/modules/audit/audit.types.ts b/apps/api/src/modules/audit/audit.types.ts index 1f52426..713b11f 100644 --- a/apps/api/src/modules/audit/audit.types.ts +++ b/apps/api/src/modules/audit/audit.types.ts @@ -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]; diff --git a/apps/api/src/modules/events/events.controller.ts b/apps/api/src/modules/events/events.controller.ts new file mode 100644 index 0000000..10783d4 --- /dev/null +++ b/apps/api/src/modules/events/events.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/events/events.module.ts b/apps/api/src/modules/events/events.module.ts new file mode 100644 index 0000000..e5b8172 --- /dev/null +++ b/apps/api/src/modules/events/events.module.ts @@ -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 {} diff --git a/apps/api/src/modules/events/events.service.ts b/apps/api/src/modules/events/events.service.ts new file mode 100644 index 0000000..9581c15 --- /dev/null +++ b/apps/api/src/modules/events/events.service.ts @@ -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' }, + }); + } +} diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts index 0b12981..607d443 100644 --- a/apps/api/src/modules/my/my.controller.ts +++ b/apps/api/src/modules/my/my.controller.ts @@ -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); diff --git a/apps/api/src/modules/my/my.service.ts b/apps/api/src/modules/my/my.service.ts index a75e69d..77b9689 100644 --- a/apps/api/src/modules/my/my.service.ts +++ b/apps/api/src/modules/my/my.service.ts @@ -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; diff --git a/apps/web/app/api/admin/events/[id]/route.ts b/apps/web/app/api/admin/events/[id]/route.ts new file mode 100644 index 0000000..6cf227c --- /dev/null +++ b/apps/web/app/api/admin/events/[id]/route.ts @@ -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 { + 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 { + const { id } = await params; + const res = await apiFetch(`/admin/events/${id}`, { method: 'DELETE' }); + const resBody = await res.json(); + return jsonResponse(resBody, res.status); +} diff --git a/apps/web/app/api/admin/events/route.ts b/apps/web/app/api/admin/events/route.ts new file mode 100644 index 0000000..6b744d7 --- /dev/null +++ b/apps/web/app/api/admin/events/route.ts @@ -0,0 +1,20 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await apiFetch('/admin/events'); + const body = await res.json(); + return jsonResponse(body, res.status); +} + +export async function POST(request: Request): Promise { + 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); +} diff --git a/apps/web/app/api/my/events/[id]/rsvp/route.ts b/apps/web/app/api/my/events/[id]/rsvp/route.ts new file mode 100644 index 0000000..4b2d80a --- /dev/null +++ b/apps/web/app/api/my/events/[id]/rsvp/route.ts @@ -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 { + 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); +} diff --git a/apps/web/app/api/my/events/route.ts b/apps/web/app/api/my/events/route.ts new file mode 100644 index 0000000..5c86264 --- /dev/null +++ b/apps/web/app/api/my/events/route.ts @@ -0,0 +1,9 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await memberApiFetch('/my/events'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/my/events/RsvpButton.tsx b/apps/web/app/my/events/RsvpButton.tsx new file mode 100644 index 0000000..979b2b2 --- /dev/null +++ b/apps/web/app/my/events/RsvpButton.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useState } from 'react'; + +type RsvpStatus = 'GOING' | 'NOT_GOING' | 'MAYBE'; + +const LABELS: Record = { + GOING: 'Going', + MAYBE: 'Maybe', + NOT_GOING: 'Not going', +}; + +const STYLES: Record = { + 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(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 ( +
+ {(['GOING', 'MAYBE', 'NOT_GOING'] as RsvpStatus[]).map((s) => ( + + ))} +
+ ); +} diff --git a/apps/web/app/my/events/page.tsx b/apps/web/app/my/events/page.tsx new file mode 100644 index 0000000..0944d93 --- /dev/null +++ b/apps/web/app/my/events/page.tsx @@ -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 { + 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 = { + 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 ( +
+
+

Events

+ ← Dashboard +
+ + {events.length === 0 && ( +
+

No events scheduled.

+

Your chapter admin will post events here.

+
+ )} + + {upcoming.length > 0 && ( +
+

Upcoming

+
+ {upcoming.map((e) => ( +
+
+
+

{e.title}

+

{formatEventDate(e.startsAt, e.endsAt)}

+ {e.location && ( +

{e.location}

+ )} +
+ {e.myRsvp && ( + + {RSVP_BADGE[e.myRsvp].label} + + )} +
+ {e.description && ( +

{e.description}

+ )} +
+ {e.rsvpCount} {e.rsvpCount === 1 ? 'response' : 'responses'} +
+ +
+ ))} +
+
+ )} + + {past.length > 0 && ( +
+

Past

+
+ {past.map((e) => ( +
+

{e.title}

+

{formatEventDate(e.startsAt, e.endsAt)}

+ {e.myRsvp && ( + + {RSVP_BADGE[e.myRsvp].label} + + )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/app/my/layout.tsx b/apps/web/app/my/layout.tsx index 2b6aa77..e92fb2e 100644 --- a/apps/web/app/my/layout.tsx +++ b/apps/web/app/my/layout.tsx @@ -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' }, ];