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:
@@ -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;
|
||||||
@@ -90,6 +90,7 @@ model Tenant {
|
|||||||
groupAccesses GroupAccess[]
|
groupAccesses GroupAccess[]
|
||||||
digestConfig DigestConfig?
|
digestConfig DigestConfig?
|
||||||
digests Digest[]
|
digests Digest[]
|
||||||
|
events Event[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AdminRole {
|
enum AdminRole {
|
||||||
@@ -387,6 +388,7 @@ model TowerUser {
|
|||||||
optOuts MemberOptOut[]
|
optOuts MemberOptOut[]
|
||||||
sessions TowerSession[]
|
sessions TowerSession[]
|
||||||
messages Message[] @relation("senderTowerUser")
|
messages Message[] @relation("senderTowerUser")
|
||||||
|
rsvps EventRsvp[]
|
||||||
|
|
||||||
@@unique([tenantId, phoneHash])
|
@@unique([tenantId, phoneHash])
|
||||||
@@index([phoneHash])
|
@@index([phoneHash])
|
||||||
@@ -528,3 +530,48 @@ model Digest {
|
|||||||
@@unique([tenantId, digestDate])
|
@@unique([tenantId, digestDate])
|
||||||
@@index([tenantId])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import { MessagesModule } from './modules/messages/messages.module';
|
|||||||
import { RulesModule } from './modules/rules/rules.module';
|
import { RulesModule } from './modules/rules/rules.module';
|
||||||
import { SuperAdminModule } from './modules/super-admin/super-admin.module';
|
import { SuperAdminModule } from './modules/super-admin/super-admin.module';
|
||||||
import { TenantModule } from './modules/tenant/tenant.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -32,6 +36,10 @@ import { TenantModule } from './modules/tenant/tenant.module';
|
|||||||
RulesModule,
|
RulesModule,
|
||||||
SuperAdminModule,
|
SuperAdminModule,
|
||||||
TenantModule,
|
TenantModule,
|
||||||
|
DigestModule,
|
||||||
|
OrgModule,
|
||||||
|
ThreadsModule,
|
||||||
|
EventsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export const AuditAction = {
|
|||||||
MEMBER_DELETED: 'MEMBER_DELETED',
|
MEMBER_DELETED: 'MEMBER_DELETED',
|
||||||
OTP_REQUESTED: 'OTP_REQUESTED',
|
OTP_REQUESTED: 'OTP_REQUESTED',
|
||||||
OTP_VERIFIED: 'OTP_VERIFIED',
|
OTP_VERIFIED: 'OTP_VERIFIED',
|
||||||
|
EVENT_CREATED: 'EVENT_CREATED',
|
||||||
|
EVENT_UPDATED: 'EVENT_UPDATED',
|
||||||
|
EVENT_DELETED: 'EVENT_DELETED',
|
||||||
|
DIGEST_SENT: 'DIGEST_SENT',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
|
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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,20 @@ export class MyController {
|
|||||||
return this.service.getDigests(member.tenantId);
|
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')
|
@Get('profile')
|
||||||
profile(@CurrentMember() member: MemberJwtPayload) {
|
profile(@CurrentMember() member: MemberJwtPayload) {
|
||||||
return this.service.getProfile(member.sub, member.tenantId);
|
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: {
|
async updateProfile(userId: string, tenantId: string, body: {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
hometown?: 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
|
|||||||
const NAV = [
|
const NAV = [
|
||||||
{ href: '/my', label: 'Dashboard' },
|
{ href: '/my', label: 'Dashboard' },
|
||||||
{ href: '/my/digest', label: 'Digest' },
|
{ href: '/my/digest', label: 'Digest' },
|
||||||
|
{ href: '/my/events', label: 'Events' },
|
||||||
{ href: '/my/groups', label: 'My Groups' },
|
{ href: '/my/groups', label: 'My Groups' },
|
||||||
{ href: '/my/settings', label: 'Settings' },
|
{ href: '/my/settings', label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user