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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user