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
+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;