From f5025327f2f26870fce2610066010880d159b041 Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sun, 14 Jun 2026 09:43:33 +0100 Subject: [PATCH 1/3] feat(organizer): my events with sales stats (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /api/organizer/events — the organizer dashboard landing. Returns the caller's events (status-filterable, paginated, newest-first) each with per-event and per-ticket-type stats (sold, available, revenue, checked-in), plus a top-level summary across ALL their events. Stats are derived with grouped aggregations (event.count, ticket.groupBy by type+status, ticketType price lookup) — no ticket rows loaded into memory. "Sold" = CONFIRMED|USED, "checkedIn" = USED, revenue = sold x current type price. 4 unit tests cover stat derivation, status exclusion, filter/pagination passthrough, and the empty-organizer case. --- .../dto/query-organizer-events.dto.ts | 21 ++ src/organizer/organizer.controller.ts | 23 +- src/organizer/organizer.service.spec.ts | 221 ++++++++++++++++++ src/organizer/organizer.service.ts | 152 +++++++++++- 4 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 src/organizer/dto/query-organizer-events.dto.ts create mode 100644 src/organizer/organizer.service.spec.ts diff --git a/src/organizer/dto/query-organizer-events.dto.ts b/src/organizer/dto/query-organizer-events.dto.ts new file mode 100644 index 0000000..68af3af --- /dev/null +++ b/src/organizer/dto/query-organizer-events.dto.ts @@ -0,0 +1,21 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsOptional, Max } from 'class-validator'; +import { EventStatus } from '@prisma/client'; +import { PaginationDto } from '../../common/dto/pagination.dto'; + +/** + * Query for `GET /api/organizer/events`. Extends the shared pagination + * DTO but caps limit at 50; status optionally filters the events list + * (the top-level summary is always computed across all of the + * organizer's events). + */ +export class QueryOrganizerEventsDto extends PaginationDto { + @IsOptional() + @Type(() => Number) + @Max(50) + declare limit: number; + + @IsOptional() + @IsEnum(EventStatus) + status?: EventStatus; +} diff --git a/src/organizer/organizer.controller.ts b/src/organizer/organizer.controller.ts index d0d9f88..ee95461 100644 --- a/src/organizer/organizer.controller.ts +++ b/src/organizer/organizer.controller.ts @@ -1,10 +1,19 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { UserRole } from '@prisma/client'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { Roles } from '../common/decorators/roles.decorator'; import { EnableMonnifyDto } from './dto/enable-monnify.dto'; import { EnablePaystackDto } from './dto/enable-paystack.dto'; +import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; import { OrganizerService } from './organizer.service'; /** @@ -21,6 +30,18 @@ import { OrganizerService } from './organizer.service'; export class OrganizerController { constructor(private readonly organizer: OrganizerService) {} + @Get('events') + @Roles(UserRole.ORGANIZER) + @ApiOperation({ + summary: 'My events with sales stats (organizer dashboard landing)', + }) + getMyEvents( + @CurrentUser('id') userId: string, + @Query() query: QueryOrganizerEventsDto, + ) { + return this.organizer.getMyEvents(userId, query); + } + @Post('providers/paystack/enable') @Roles(UserRole.ORGANIZER) @HttpCode(HttpStatus.OK) diff --git a/src/organizer/organizer.service.spec.ts b/src/organizer/organizer.service.spec.ts new file mode 100644 index 0000000..3fd9e18 --- /dev/null +++ b/src/organizer/organizer.service.spec.ts @@ -0,0 +1,221 @@ +import { EventStatus, TicketStatus } from '@prisma/client'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../prisma/prisma.service'; +import { PaystackService } from '../paystack/paystack.service'; +import { MonnifyProvider } from '../payments/providers/monnify.provider'; +import { OrganizerService } from './organizer.service'; +import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; + +/** + * getMyEvents leans on prisma aggregations. We stub each call the method + * makes, in order, so we can assert the derived stats without a DB. + */ +function setup(opts: { + totalEvents?: number; + publishedEvents?: number; + soldGroupsAll?: { ticketTypeId: string; _count: { _all: number } }[]; + typePrices?: { id: string; price: number }[]; + events?: unknown[]; + total?: number; + ticketGroups?: { + ticketTypeId: string; + status: TicketStatus; + _count: { _all: number }; + }[]; +}) { + const eventCount = jest + .fn() + .mockResolvedValueOnce(opts.totalEvents ?? 0) // totalEvents + .mockResolvedValueOnce(opts.publishedEvents ?? 0) // publishedEvents + .mockResolvedValueOnce(opts.total ?? 0); // pagination total + const eventFindMany = jest.fn().mockResolvedValue(opts.events ?? []); + + const ticketGroupBy = jest + .fn() + .mockResolvedValueOnce(opts.soldGroupsAll ?? []) // summary groupBy + .mockResolvedValueOnce(opts.ticketGroups ?? []); // page groupBy + const ticketTypeFindMany = jest.fn().mockResolvedValue(opts.typePrices ?? []); + + const prisma = { + event: { count: eventCount, findMany: eventFindMany }, + ticket: { groupBy: ticketGroupBy }, + ticketType: { findMany: ticketTypeFindMany }, + } as unknown as PrismaService; + + const svc = new OrganizerService( + prisma, + { get: () => undefined } as unknown as ConfigService, + {} as unknown as PaystackService, + {} as unknown as MonnifyProvider, + ); + return { svc, eventFindMany }; +} + +function query( + overrides: Partial = {}, +): QueryOrganizerEventsDto { + return { + page: 1, + limit: 10, + skip: 0, + ...overrides, + } as QueryOrganizerEventsDto; +} + +function eventRow(overrides: Record = {}) { + return { + id: 'event-1', + name: 'Lagos Tech Summit', + slug: 'lagos-tech-summit', + coverImage: null, + startTime: new Date('2026-05-15T09:00:00Z'), + endTime: new Date('2026-05-16T18:00:00Z'), + status: EventStatus.PUBLISHED, + category: 'CONFERENCE', + createdAt: new Date('2026-03-12T10:00:00Z'), + ticketTypes: [ + { id: 'tt-gen', name: 'General', price: 15000, quantity: 500 }, + { id: 'tt-vip', name: 'VIP', price: 50000, quantity: 100 }, + ], + ...overrides, + }; +} + +describe('OrganizerService.getMyEvents', () => { + it('derives per-type, per-event, and summary stats', async () => { + const { svc } = setup({ + totalEvents: 1, + publishedEvents: 1, + total: 1, + // summary: 120 General + 45 VIP sold across all events + soldGroupsAll: [ + { ticketTypeId: 'tt-gen', _count: { _all: 120 } }, + { ticketTypeId: 'tt-vip', _count: { _all: 45 } }, + ], + typePrices: [ + { id: 'tt-gen', price: 15000 }, + { id: 'tt-vip', price: 50000 }, + ], + events: [eventRow()], + // page: General 120 sold (10 checked in), VIP 45 sold (0 checked in) + ticketGroups: [ + { + ticketTypeId: 'tt-gen', + status: TicketStatus.CONFIRMED, + _count: { _all: 110 }, + }, + { + ticketTypeId: 'tt-gen', + status: TicketStatus.USED, + _count: { _all: 10 }, + }, + { + ticketTypeId: 'tt-vip', + status: TicketStatus.CONFIRMED, + _count: { _all: 45 }, + }, + ], + }); + + const res = await svc.getMyEvents('org-1', query()); + + expect(res.summary).toEqual({ + totalEvents: 1, + publishedEvents: 1, + totalTicketsSold: 165, + totalRevenue: 120 * 15000 + 45 * 50000, // 4,050,000 + }); + + const [event] = res.events; + expect(event.stats).toEqual({ + totalTickets: 600, + ticketsSold: 165, + ticketsAvailable: 435, + totalRevenue: 120 * 15000 + 45 * 50000, + checkedIn: 10, + ticketTypes: [ + { name: 'General', sold: 120, total: 500, revenue: 1_800_000 }, + { name: 'VIP', sold: 45, total: 100, revenue: 2_250_000 }, + ], + }); + // ticketTypes relation is replaced by stats, not leaked on the event. + expect(event).not.toHaveProperty('ticketTypes'); + expect(res.pagination).toEqual({ + page: 1, + limit: 10, + total: 1, + totalPages: 1, + }); + }); + + it('excludes PENDING/CANCELLED/REFUNDED from sold and checked-in', async () => { + const { svc } = setup({ + totalEvents: 1, + publishedEvents: 1, + total: 1, + soldGroupsAll: [{ ticketTypeId: 'tt-gen', _count: { _all: 2 } }], + typePrices: [{ id: 'tt-gen', price: 15000 }], + events: [ + eventRow({ + ticketTypes: [ + { id: 'tt-gen', name: 'General', price: 15000, quantity: 500 }, + ], + }), + ], + ticketGroups: [ + { + ticketTypeId: 'tt-gen', + status: TicketStatus.CONFIRMED, + _count: { _all: 2 }, + }, + { + ticketTypeId: 'tt-gen', + status: TicketStatus.PENDING, + _count: { _all: 7 }, + }, + { + ticketTypeId: 'tt-gen', + status: TicketStatus.CANCELLED, + _count: { _all: 3 }, + }, + ], + }); + + const res = await svc.getMyEvents('org-1', query()); + expect(res.events[0].stats.ticketsSold).toBe(2); + expect(res.events[0].stats.checkedIn).toBe(0); + }); + + it('passes the status filter and pagination into the events query', async () => { + const { svc, eventFindMany } = setup({ + events: [], + total: 0, + }); + + await svc.getMyEvents( + 'org-1', + query({ page: 2, limit: 5, skip: 5, status: EventStatus.DRAFT }), + ); + + expect(eventFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { organizerId: 'org-1', status: EventStatus.DRAFT }, + orderBy: { createdAt: 'desc' }, + skip: 5, + take: 5, + }), + ); + }); + + it('returns zeroed summary and empty list for an organizer with no events', async () => { + const { svc } = setup({}); + const res = await svc.getMyEvents('org-1', query()); + expect(res.summary).toEqual({ + totalEvents: 0, + publishedEvents: 0, + totalRevenue: 0, + totalTicketsSold: 0, + }); + expect(res.events).toEqual([]); + }); +}); diff --git a/src/organizer/organizer.service.ts b/src/organizer/organizer.service.ts index 965fe40..2405679 100644 --- a/src/organizer/organizer.service.ts +++ b/src/organizer/organizer.service.ts @@ -7,12 +7,19 @@ import { NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { UserRole } from '@prisma/client'; +import { EventStatus, Prisma, TicketStatus, UserRole } from '@prisma/client'; import { MonnifyProvider } from '../payments/providers/monnify.provider'; import { PaystackService } from '../paystack/paystack.service'; import { PrismaService } from '../prisma/prisma.service'; import { EnableMonnifyDto } from './dto/enable-monnify.dto'; import { EnablePaystackDto } from './dto/enable-paystack.dto'; +import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; + +/** Ticket statuses that count as a completed sale. */ +const SOLD_STATUSES: TicketStatus[] = [ + TicketStatus.CONFIRMED, + TicketStatus.USED, +]; /** * Per-provider fiat enablement for organizers. @@ -38,6 +45,149 @@ export class OrganizerService { private readonly monnify: MonnifyProvider, ) {} + /** + * Organizer dashboard landing: the caller's events with per-event and + * per-ticket-type sales stats, plus a top-level summary computed across + * ALL their events (not just the current page). + * + * Stats are derived with grouped aggregations (groupBy + _count) rather + * than loading ticket rows. "Sold" = CONFIRMED or USED; "checkedIn" = + * USED. Revenue is sold count x current ticket-type price. + */ + async getMyEvents(userId: string, query: QueryOrganizerEventsDto) { + // ---- summary across ALL the organizer's events ---- + const [totalEvents, publishedEvents, soldGroupsAll, typePrices] = + await Promise.all([ + this.prisma.event.count({ where: { organizerId: userId } }), + this.prisma.event.count({ + where: { organizerId: userId, status: EventStatus.PUBLISHED }, + }), + this.prisma.ticket.groupBy({ + by: ['ticketTypeId'], + where: { + event: { organizerId: userId }, + status: { in: SOLD_STATUSES }, + }, + _count: { _all: true }, + }), + this.prisma.ticketType.findMany({ + where: { event: { organizerId: userId } }, + select: { id: true, price: true }, + }), + ]); + + const priceById = new Map( + typePrices.map((t) => [t.id, Number(t.price)] as const), + ); + let totalTicketsSold = 0; + let totalRevenue = 0; + for (const g of soldGroupsAll) { + const sold = g._count._all; + totalTicketsSold += sold; + totalRevenue += sold * (priceById.get(g.ticketTypeId) ?? 0); + } + + // ---- paginated events list (status-filtered) ---- + const where: Prisma.EventWhereInput = { organizerId: userId }; + if (query.status) { + where.status = query.status; + } + + const [events, total] = await Promise.all([ + this.prisma.event.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: query.skip, + take: query.limit, + select: { + id: true, + name: true, + slug: true, + coverImage: true, + startTime: true, + endTime: true, + status: true, + category: true, + createdAt: true, + ticketTypes: { + select: { id: true, name: true, price: true, quantity: true }, + }, + }, + }), + this.prisma.event.count({ where }), + ]); + + // One grouped read for every ticket on the page, split by type + status. + const pageEventIds = events.map((e) => e.id); + const ticketGroups = pageEventIds.length + ? await this.prisma.ticket.groupBy({ + by: ['ticketTypeId', 'status'], + where: { eventId: { in: pageEventIds } }, + _count: { _all: true }, + }) + : []; + + const statsByType = new Map(); + for (const g of ticketGroups) { + const entry = statsByType.get(g.ticketTypeId) ?? { + sold: 0, + checkedIn: 0, + }; + if (SOLD_STATUSES.includes(g.status)) { + entry.sold += g._count._all; + } + if (g.status === TicketStatus.USED) { + entry.checkedIn += g._count._all; + } + statsByType.set(g.ticketTypeId, entry); + } + + const eventsWithStats = events.map((event) => { + let totalTickets = 0; + let ticketsSold = 0; + let revenue = 0; + let checkedIn = 0; + const ticketTypes = event.ticketTypes.map((tt) => { + const s = statsByType.get(tt.id) ?? { sold: 0, checkedIn: 0 }; + const typeRevenue = s.sold * Number(tt.price); + totalTickets += tt.quantity; + ticketsSold += s.sold; + revenue += typeRevenue; + checkedIn += s.checkedIn; + return { + name: tt.name, + sold: s.sold, + total: tt.quantity, + revenue: typeRevenue, + }; + }); + + const { ticketTypes: _types, ...rest } = event; + return { + ...rest, + stats: { + totalTickets, + ticketsSold, + ticketsAvailable: totalTickets - ticketsSold, + totalRevenue: revenue, + checkedIn, + ticketTypes, + }, + }; + }); + + return { + summary: { totalEvents, publishedEvents, totalRevenue, totalTicketsSold }, + events: eventsWithStats, + pagination: { + page: query.page, + limit: query.limit, + total, + totalPages: Math.ceil(total / query.limit), + }, + }; + } + async enablePaystack(userId: string, dto: EnablePaystackDto) { const { profile, user } = await this.loadOrganizer(userId); From fc7ccce0064b3aca74b7b3265e1d7444a881a89e Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sun, 14 Jun 2026 09:50:00 +0100 Subject: [PATCH 2/3] feat(organizer): single-event analytics (#44) Add GET /api/organizer/events/:id/analytics (owner or admin; 404/403 guarded). Returns overview KPIs (revenue, sold, available, checked-in, check-in rate, avg ticket price), gap-filled daily sales for charting, and breakdowns by ticket type, payment provider, and payment channel. Revenue/provider/channel are derived from SUCCESS transactions (channel read from metadata, persisted at settlement); sold/check-in counts from ticket.groupBy. Daily series is zero-filled from the first sale day to today so the chart has no date holes. 7 unit tests cover auth, KPI/ breakdown math, gap-fill, unknown-channel bucketing, and the no-sales case. --- src/organizer/organizer.controller.ts | 13 ++ src/organizer/organizer.service.spec.ts | 200 +++++++++++++++++++++++- src/organizer/organizer.service.ts | 191 +++++++++++++++++++++- 3 files changed, 402 insertions(+), 2 deletions(-) diff --git a/src/organizer/organizer.controller.ts b/src/organizer/organizer.controller.ts index ee95461..9c0d63f 100644 --- a/src/organizer/organizer.controller.ts +++ b/src/organizer/organizer.controller.ts @@ -4,6 +4,7 @@ import { Get, HttpCode, HttpStatus, + Param, Post, Query, } from '@nestjs/common'; @@ -42,6 +43,18 @@ export class OrganizerController { return this.organizer.getMyEvents(userId, query); } + @Get('events/:id/analytics') + @Roles(UserRole.ORGANIZER, UserRole.ADMIN) + @ApiOperation({ + summary: 'Event analytics (daily sales, breakdowns, check-in rate)', + }) + getEventAnalytics( + @Param('id') id: string, + @CurrentUser() actor: { id: string; role: UserRole }, + ) { + return this.organizer.getEventAnalytics(id, actor); + } + @Post('providers/paystack/enable') @Roles(UserRole.ORGANIZER) @HttpCode(HttpStatus.OK) diff --git a/src/organizer/organizer.service.spec.ts b/src/organizer/organizer.service.spec.ts index 3fd9e18..1dd2471 100644 --- a/src/organizer/organizer.service.spec.ts +++ b/src/organizer/organizer.service.spec.ts @@ -1,4 +1,5 @@ -import { EventStatus, TicketStatus } from '@prisma/client'; +import { EventStatus, TicketStatus, UserRole } from '@prisma/client'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; import { PaystackService } from '../paystack/paystack.service'; @@ -219,3 +220,200 @@ describe('OrganizerService.getMyEvents', () => { expect(res.events).toEqual([]); }); }); + +describe('OrganizerService.getEventAnalytics', () => { + const ORG = 'org-1'; + const DAY = 24 * 60 * 60 * 1000; + + function analyticsSvc(opts: { + event?: unknown; + txns?: unknown[]; + ticketGroups?: { + ticketTypeId: string; + status: TicketStatus; + _count: { _all: number }; + }[]; + }) { + const findUnique = jest + .fn() + .mockResolvedValue( + opts.event === undefined ? eventForAnalytics() : opts.event, + ); + const txnFindMany = jest.fn().mockResolvedValue(opts.txns ?? []); + const ticketGroupBy = jest.fn().mockResolvedValue(opts.ticketGroups ?? []); + + const prisma = { + event: { findUnique }, + transaction: { findMany: txnFindMany }, + ticket: { groupBy: ticketGroupBy }, + } as unknown as PrismaService; + + const svc = new OrganizerService( + prisma, + { get: () => undefined } as unknown as ConfigService, + {} as unknown as PaystackService, + {} as unknown as MonnifyProvider, + ); + return svc; + } + + function eventForAnalytics(overrides: Record = {}) { + return { + id: 'event-1', + name: 'Lagos Tech Summit', + status: EventStatus.PUBLISHED, + startTime: new Date('2026-05-15T09:00:00Z'), + organizerId: ORG, + ticketTypes: [ + { id: 'tt-gen', name: 'General', price: 15000, quantity: 500 }, + { id: 'tt-vip', name: 'VIP', price: 50000, quantity: 100 }, + ], + ...overrides, + }; + } + + const admin = { id: 'admin-9', role: UserRole.ADMIN }; + const owner = { id: ORG, role: UserRole.ORGANIZER }; + + it('404 when the event does not exist', async () => { + const svc = analyticsSvc({ event: null }); + await expect(svc.getEventAnalytics('nope', owner)).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('403 when the caller is neither owner nor admin', async () => { + const svc = analyticsSvc({}); + await expect( + svc.getEventAnalytics('event-1', { + id: 'intruder', + role: UserRole.ORGANIZER, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('allows an admin who does not own the event', async () => { + const svc = analyticsSvc({}); + const res = await svc.getEventAnalytics('event-1', admin); + expect(res.event.id).toBe('event-1'); + }); + + it('computes overview, breakdowns, and provider/channel splits', async () => { + const svc = analyticsSvc({ + txns: [ + { + amount: 1_800_000, + provider: 'PAYSTACK', + quantity: 120, + createdAt: new Date('2026-04-01T10:00:00Z'), + metadata: { channel: 'card' }, + }, + { + amount: 2_250_000, + provider: 'MONNIFY', + quantity: 45, + createdAt: new Date('2026-04-01T12:00:00Z'), + metadata: { channel: 'bank_transfer' }, + }, + ], + ticketGroups: [ + { + ticketTypeId: 'tt-gen', + status: TicketStatus.CONFIRMED, + _count: { _all: 110 }, + }, + { + ticketTypeId: 'tt-gen', + status: TicketStatus.USED, + _count: { _all: 10 }, + }, + { + ticketTypeId: 'tt-vip', + status: TicketStatus.CONFIRMED, + _count: { _all: 45 }, + }, + ], + }); + + const res = await svc.getEventAnalytics('event-1', owner); + + expect(res.overview).toEqual({ + totalRevenue: 4_050_000, + totalTicketsSold: 165, + totalTicketsAvailable: 435, + totalCheckedIn: 10, + checkInRate: 6, // round(10/165*100) + averageTicketPrice: Math.round(4_050_000 / 165), + }); + expect(res.ticketTypeBreakdown).toEqual([ + { + name: 'General', + price: 15000, + sold: 120, + total: 500, + revenue: 1_800_000, + percentSold: 24, + }, + { + name: 'VIP', + price: 50000, + sold: 45, + total: 100, + revenue: 2_250_000, + percentSold: 45, + }, + ]); + expect(res.revenueByProvider).toEqual([ + { provider: 'PAYSTACK', amount: 1_800_000, count: 120 }, + { provider: 'MONNIFY', amount: 2_250_000, count: 45 }, + ]); + expect(res.revenueByChannel).toEqual([ + { channel: 'card', amount: 1_800_000, count: 120 }, + { channel: 'bank_transfer', amount: 2_250_000, count: 45 }, + ]); + }); + + it('gap-fills daily sales between sale days (no date holes)', async () => { + const start = new Date('2026-04-01T10:00:00Z'); + const twoDaysLater = new Date(start.getTime() + 2 * DAY); + const svc = analyticsSvc({ + txns: [ + { + amount: 100, + provider: 'PAYSTACK', + quantity: 1, + createdAt: start, + metadata: {}, + }, + { + amount: 300, + provider: 'PAYSTACK', + quantity: 3, + createdAt: twoDaysLater, + metadata: {}, + }, + ], + }); + + const res = await svc.getEventAnalytics('event-1', owner); + + // First three entries are contiguous days with the middle zero-filled. + expect(res.dailySales.slice(0, 3)).toEqual([ + { date: '2026-04-01', ticketsSold: 1, revenue: 100 }, + { date: '2026-04-02', ticketsSold: 0, revenue: 0 }, + { date: '2026-04-03', ticketsSold: 3, revenue: 300 }, + ]); + // missing channel buckets under 'unknown' + expect(res.revenueByChannel).toEqual([ + { channel: 'unknown', amount: 400, count: 4 }, + ]); + }); + + it('zeroes rate/average when nothing has sold', async () => { + const svc = analyticsSvc({ txns: [], ticketGroups: [] }); + const res = await svc.getEventAnalytics('event-1', owner); + expect(res.overview.checkInRate).toBe(0); + expect(res.overview.averageTicketPrice).toBe(0); + expect(res.dailySales).toEqual([]); + }); +}); diff --git a/src/organizer/organizer.service.ts b/src/organizer/organizer.service.ts index 2405679..462973c 100644 --- a/src/organizer/organizer.service.ts +++ b/src/organizer/organizer.service.ts @@ -7,7 +7,13 @@ import { NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { EventStatus, Prisma, TicketStatus, UserRole } from '@prisma/client'; +import { + EventStatus, + Prisma, + TicketStatus, + TransactionStatus, + UserRole, +} from '@prisma/client'; import { MonnifyProvider } from '../payments/providers/monnify.provider'; import { PaystackService } from '../paystack/paystack.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -21,6 +27,27 @@ const SOLD_STATUSES: TicketStatus[] = [ TicketStatus.USED, ]; +/** UTC YYYY-MM-DD key for a timestamp. */ +function dayKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** Inclusive list of UTC day keys from `from` to `to`. */ +function eachDay(from: Date, to: Date): string[] { + const out: string[] = []; + const cur = new Date( + Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()), + ); + const end = new Date( + Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), to.getUTCDate()), + ); + while (cur <= end) { + out.push(cur.toISOString().slice(0, 10)); + cur.setUTCDate(cur.getUTCDate() + 1); + } + return out; +} + /** * Per-provider fiat enablement for organizers. * @@ -188,6 +215,168 @@ export class OrganizerService { }; } + /** + * Single-event analytics for the owning organizer (or an admin): + * overview KPIs, daily sales (gap-filled for chart continuity), and + * breakdowns by ticket type, payment provider, and payment channel. + * + * Revenue / provider / channel come from SUCCESS transactions (one row + * per purchase, with `channel` persisted into metadata on settlement); + * sold/check-in counts come from the ticket table. + */ + async getEventAnalytics( + eventId: string, + actor: { id: string; role: UserRole }, + ) { + const event = await this.prisma.event.findUnique({ + where: { id: eventId }, + select: { + id: true, + name: true, + status: true, + startTime: true, + organizerId: true, + ticketTypes: { + select: { id: true, name: true, price: true, quantity: true }, + }, + }, + }); + if (!event) { + throw new NotFoundException('Event not found'); + } + if (actor.role !== UserRole.ADMIN && event.organizerId !== actor.id) { + throw new ForbiddenException('You do not have access to this event'); + } + + const [txns, ticketGroups] = await Promise.all([ + this.prisma.transaction.findMany({ + where: { eventId, status: TransactionStatus.SUCCESS }, + select: { + amount: true, + provider: true, + quantity: true, + createdAt: true, + metadata: true, + }, + }), + this.prisma.ticket.groupBy({ + by: ['ticketTypeId', 'status'], + where: { eventId }, + _count: { _all: true }, + }), + ]); + + // --- revenue / provider / channel / daily, from transactions --- + let totalRevenue = 0; + const provMap = new Map(); + const chanMap = new Map(); + const byDay = new Map(); + + for (const t of txns) { + const amount = Number(t.amount); + totalRevenue += amount; + + const prov = provMap.get(t.provider) ?? { amount: 0, count: 0 }; + prov.amount += amount; + prov.count += t.quantity; + provMap.set(t.provider, prov); + + const channel = + ((t.metadata as Record | null)?.channel as + | string + | undefined) ?? 'unknown'; + const chan = chanMap.get(channel) ?? { amount: 0, count: 0 }; + chan.amount += amount; + chan.count += t.quantity; + chanMap.set(channel, chan); + + const day = dayKey(t.createdAt); + const d = byDay.get(day) ?? { ticketsSold: 0, revenue: 0 }; + d.ticketsSold += t.quantity; + d.revenue += amount; + byDay.set(day, d); + } + + const revenueByProvider = [...provMap.entries()].map(([provider, v]) => ({ + provider, + amount: v.amount, + count: v.count, + })); + const revenueByChannel = [...chanMap.entries()].map(([channel, v]) => ({ + channel, + amount: v.amount, + count: v.count, + })); + + // Gap-filled daily series: first sale day → today, zeros for empty days. + const dailySales: { date: string; ticketsSold: number; revenue: number }[] = + []; + if (byDay.size > 0) { + const first = new Date(`${[...byDay.keys()].sort()[0]}T00:00:00Z`); + for (const day of eachDay(first, new Date())) { + const d = byDay.get(day) ?? { ticketsSold: 0, revenue: 0 }; + dailySales.push({ date: day, ...d }); + } + } + + // --- ticket-type breakdown + sold/check-in, from tickets --- + const statsByType = new Map(); + for (const g of ticketGroups) { + const e = statsByType.get(g.ticketTypeId) ?? { sold: 0, checkedIn: 0 }; + if (SOLD_STATUSES.includes(g.status)) { + e.sold += g._count._all; + } + if (g.status === TicketStatus.USED) { + e.checkedIn += g._count._all; + } + statsByType.set(g.ticketTypeId, e); + } + + let totalTicketsSold = 0; + let totalCheckedIn = 0; + let totalCapacity = 0; + const ticketTypeBreakdown = event.ticketTypes.map((tt) => { + const s = statsByType.get(tt.id) ?? { sold: 0, checkedIn: 0 }; + const price = Number(tt.price); + totalTicketsSold += s.sold; + totalCheckedIn += s.checkedIn; + totalCapacity += tt.quantity; + return { + name: tt.name, + price, + sold: s.sold, + total: tt.quantity, + revenue: s.sold * price, + percentSold: tt.quantity ? Math.round((s.sold / tt.quantity) * 100) : 0, + }; + }); + + return { + event: { + id: event.id, + name: event.name, + status: event.status, + startTime: event.startTime, + }, + overview: { + totalRevenue, + totalTicketsSold, + totalTicketsAvailable: totalCapacity - totalTicketsSold, + totalCheckedIn, + checkInRate: totalTicketsSold + ? Math.round((totalCheckedIn / totalTicketsSold) * 100) + : 0, + averageTicketPrice: totalTicketsSold + ? Math.round(totalRevenue / totalTicketsSold) + : 0, + }, + dailySales, + ticketTypeBreakdown, + revenueByProvider, + revenueByChannel, + }; + } + async enablePaystack(userId: string, dto: EnablePaystackDto) { const { profile, user } = await this.loadOrganizer(userId); From 29fa3e4c2ea6fb62f6111135f566b10586b651d7 Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sun, 14 Jun 2026 09:56:42 +0100 Subject: [PATCH 3/3] feat(organizer): attendee list + CSV export (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two owner/admin endpoints for an event's attendees: - GET /organizer/events/:id/attendees — paginated roster (default 50, max 200) filterable by status, ticketTypeId, and name/email search (case-insensitive), with event-wide summary counts (total, confirmed, checked-in, cancelled). - GET /organizer/events/:id/attendees/export — full CSV download with RFC-4180 escaping and a {slug}-attendees.csv filename. Plumbing: add a @SkipTransform() decorator + Reflector check in TransformInterceptor so the CSV route returns its raw body instead of the {success,data} envelope (wired via app.get(Reflector) in main.ts). shared assertEventAccess() guards both endpoints (404/403). 8 tests cover auth, row mapping, summary counts, filter passthrough, and CSV header/escaping/filename. --- .../decorators/skip-transform.decorator.ts | 10 ++ .../interceptors/transform.interceptor.ts | 20 ++- src/main.ts | 11 +- src/organizer/dto/query-attendees.dto.ts | 38 ++++ src/organizer/organizer.controller.ts | 35 ++++ src/organizer/organizer.service.spec.ts | 154 ++++++++++++++++ src/organizer/organizer.service.ts | 165 ++++++++++++++++++ 7 files changed, 424 insertions(+), 9 deletions(-) create mode 100644 src/common/decorators/skip-transform.decorator.ts create mode 100644 src/organizer/dto/query-attendees.dto.ts diff --git a/src/common/decorators/skip-transform.decorator.ts b/src/common/decorators/skip-transform.decorator.ts new file mode 100644 index 0000000..de07222 --- /dev/null +++ b/src/common/decorators/skip-transform.decorator.ts @@ -0,0 +1,10 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SKIP_TRANSFORM = 'skipTransform'; + +/** + * Marks a route handler whose response should NOT be wrapped in the + * standard `{ success, data, timestamp }` envelope by TransformInterceptor. + * Use for raw payloads like file/CSV downloads. + */ +export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM, true); diff --git a/src/common/interceptors/transform.interceptor.ts b/src/common/interceptors/transform.interceptor.ts index 05fc4cd..917a1d3 100644 --- a/src/common/interceptors/transform.interceptor.ts +++ b/src/common/interceptors/transform.interceptor.ts @@ -4,7 +4,9 @@ import { ExecutionContext, CallHandler, } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { Observable, map } from 'rxjs'; +import { SKIP_TRANSFORM } from '../decorators/skip-transform.decorator'; export interface SuccessResponse { success: boolean; @@ -15,12 +17,24 @@ export interface SuccessResponse { @Injectable() export class TransformInterceptor implements NestInterceptor< T, - SuccessResponse + SuccessResponse | T > { + constructor(private readonly reflector: Reflector) {} + intercept( - _context: ExecutionContext, + context: ExecutionContext, next: CallHandler, - ): Observable> { + ): Observable | T> { + // Routes marked @SkipTransform (e.g. file/CSV downloads) return their + // raw payload unwrapped. + const skip = this.reflector.getAllAndOverride(SKIP_TRANSFORM, [ + context.getHandler(), + context.getClass(), + ]); + if (skip) { + return next.handle(); + } + return next.handle().pipe( map((data) => ({ success: true, diff --git a/src/main.ts b/src/main.ts index 1be8839..af46ecf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { NestFactory } from '@nestjs/core'; +import { NestFactory, Reflector } from '@nestjs/core'; import { ValidationPipe, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -11,10 +11,9 @@ import { TransformInterceptor } from './common/interceptors/transform.intercepto // columns like TicketType.onChainTicketId (on-chain uint64), and the // default serializer throws "Do not know how to serialize a BigInt". // Emit as a string to avoid precision loss beyond Number.MAX_SAFE_INTEGER. -(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = - function () { - return this.toString(); - }; +(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () { + return this.toString(); +}; async function bootstrap() { // rawBody enables Paystack/Monnify webhook signature verification @@ -78,7 +77,7 @@ async function bootstrap() { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors( new LoggingInterceptor(), - new TransformInterceptor(), + new TransformInterceptor(app.get(Reflector)), ); // Swagger diff --git a/src/organizer/dto/query-attendees.dto.ts b/src/organizer/dto/query-attendees.dto.ts new file mode 100644 index 0000000..e3f02a6 --- /dev/null +++ b/src/organizer/dto/query-attendees.dto.ts @@ -0,0 +1,38 @@ +import { Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + Min, +} from 'class-validator'; +import { TicketStatus } from '@prisma/client'; +import { PaginationDto } from '../../common/dto/pagination.dto'; + +/** + * Query for `GET /api/organizer/events/:id/attendees`. Attendee lists use + * a higher default page size (50) and ceiling (200) than the standard + * pagination DTO — event-day rosters are long. + */ +export class QueryAttendeesDto extends PaginationDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit: number = 50; + + @IsOptional() + @IsEnum(TicketStatus) + status?: TicketStatus; + + @IsOptional() + @IsUUID() + ticketTypeId?: string; + + @IsOptional() + @IsString() + search?: string; +} diff --git a/src/organizer/organizer.controller.ts b/src/organizer/organizer.controller.ts index 9c0d63f..66c8786 100644 --- a/src/organizer/organizer.controller.ts +++ b/src/organizer/organizer.controller.ts @@ -7,14 +7,18 @@ import { Param, Post, Query, + Res, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { UserRole } from '@prisma/client'; +import type { Response } from 'express'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { Roles } from '../common/decorators/roles.decorator'; +import { SkipTransform } from '../common/decorators/skip-transform.decorator'; import { EnableMonnifyDto } from './dto/enable-monnify.dto'; import { EnablePaystackDto } from './dto/enable-paystack.dto'; import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; +import { QueryAttendeesDto } from './dto/query-attendees.dto'; import { OrganizerService } from './organizer.service'; /** @@ -55,6 +59,37 @@ export class OrganizerController { return this.organizer.getEventAnalytics(id, actor); } + @Get('events/:id/attendees') + @Roles(UserRole.ORGANIZER, UserRole.ADMIN) + @ApiOperation({ summary: 'Attendee list for an event (filterable)' }) + getAttendees( + @Param('id') id: string, + @Query() query: QueryAttendeesDto, + @CurrentUser() actor: { id: string; role: UserRole }, + ) { + return this.organizer.getAttendees(id, query, actor); + } + + @Get('events/:id/attendees/export') + @Roles(UserRole.ORGANIZER, UserRole.ADMIN) + @SkipTransform() + @ApiOperation({ summary: 'Export attendees as a CSV download' }) + async exportAttendees( + @Param('id') id: string, + @CurrentUser() actor: { id: string; role: UserRole }, + @Res({ passthrough: true }) res: Response, + ): Promise { + const { filename, csv } = await this.organizer.exportAttendeesCSV( + id, + actor, + ); + res.set({ + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }); + return csv; + } + @Post('providers/paystack/enable') @Roles(UserRole.ORGANIZER) @HttpCode(HttpStatus.OK) diff --git a/src/organizer/organizer.service.spec.ts b/src/organizer/organizer.service.spec.ts index 1dd2471..1e0a097 100644 --- a/src/organizer/organizer.service.spec.ts +++ b/src/organizer/organizer.service.spec.ts @@ -417,3 +417,157 @@ describe('OrganizerService.getEventAnalytics', () => { expect(res.dailySales).toEqual([]); }); }); + +describe('OrganizerService.getAttendees / exportAttendeesCSV', () => { + const ORG = 'org-1'; + const owner = { id: ORG, role: UserRole.ORGANIZER }; + + function attendeeRow(overrides: Record = {}) { + return { + reference: 'HOSTIT_TKT_A3F2B9C1', + buyerName: 'Jane Doe', + buyerEmail: 'jane@example.com', + buyerPhone: '+2348012345678', + status: TicketStatus.CONFIRMED, + checkedInAt: null, + createdAt: new Date('2026-04-05T10:00:00Z'), + ticketType: { name: 'VIP' }, + ...overrides, + }; + } + + function svcWith(opts: { + event?: unknown; + rows?: unknown[]; + total?: number; + statusGroups?: { status: TicketStatus; _count: { _all: number } }[]; + }) { + const eventFindUnique = jest + .fn() + .mockResolvedValue( + opts.event === undefined + ? { id: 'event-1', slug: 'lagos-tech-summit', organizerId: ORG } + : opts.event, + ); + const ticketFindMany = jest.fn().mockResolvedValue(opts.rows ?? []); + const ticketCount = jest.fn().mockResolvedValue(opts.total ?? 0); + const ticketGroupBy = jest.fn().mockResolvedValue(opts.statusGroups ?? []); + + const prisma = { + event: { findUnique: eventFindUnique }, + ticket: { + findMany: ticketFindMany, + count: ticketCount, + groupBy: ticketGroupBy, + }, + } as unknown as PrismaService; + + const svc = new OrganizerService( + prisma, + { get: () => undefined } as unknown as ConfigService, + {} as unknown as PaystackService, + {} as unknown as MonnifyProvider, + ); + return { svc, ticketFindMany }; + } + + function attendeeQuery(overrides = {}) { + return { page: 1, limit: 50, skip: 0, ...overrides } as never; + } + + it('404 when the event does not exist', async () => { + const { svc } = svcWith({ event: null }); + await expect( + svc.getAttendees('nope', attendeeQuery(), owner), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('403 when the caller is not owner/admin', async () => { + const { svc } = svcWith({}); + await expect( + svc.getAttendees('event-1', attendeeQuery(), { + id: 'intruder', + role: UserRole.ORGANIZER, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('maps rows and computes event-wide summary counts', async () => { + const { svc } = svcWith({ + rows: [attendeeRow()], + total: 1, + statusGroups: [ + { status: TicketStatus.CONFIRMED, _count: { _all: 140 } }, + { status: TicketStatus.USED, _count: { _all: 0 } }, + { status: TicketStatus.CANCELLED, _count: { _all: 25 } }, + ], + }); + + const res = await svc.getAttendees('event-1', attendeeQuery(), owner); + + expect(res.attendees[0]).toEqual({ + ticketReference: 'HOSTIT_TKT_A3F2B9C1', + buyerName: 'Jane Doe', + buyerEmail: 'jane@example.com', + buyerPhone: '+2348012345678', + ticketType: 'VIP', + status: TicketStatus.CONFIRMED, + checkedInAt: null, + purchasedAt: new Date('2026-04-05T10:00:00Z'), + }); + expect(res.summary).toEqual({ + totalAttendees: 165, + confirmed: 140, + checkedIn: 0, + cancelled: 25, + }); + }); + + it('applies status, ticketType, and search filters to the query', async () => { + const { svc, ticketFindMany } = svcWith({ rows: [], total: 0 }); + + await svc.getAttendees( + 'event-1', + attendeeQuery({ + status: TicketStatus.USED, + ticketTypeId: 'tt-vip', + search: 'jane', + }), + owner, + ); + + expect(ticketFindMany.mock.calls[0][0].where).toEqual({ + eventId: 'event-1', + status: TicketStatus.USED, + ticketTypeId: 'tt-vip', + OR: [ + { buyerName: { contains: 'jane', mode: 'insensitive' } }, + { buyerEmail: { contains: 'jane', mode: 'insensitive' } }, + ], + }); + }); + + it('exports a CSV with header, slug filename, and RFC-4180 escaping', async () => { + const { svc } = svcWith({ + rows: [ + attendeeRow({ + buyerName: 'Doe, Jane "JD"', // comma + quotes → must be quoted/escaped + buyerPhone: null, // empty cell + checkedInAt: new Date('2026-05-15T09:30:00Z'), + status: TicketStatus.USED, + }), + ], + }); + + const { filename, csv } = await svc.exportAttendeesCSV('event-1', owner); + + expect(filename).toBe('lagos-tech-summit-attendees.csv'); + const [header, row] = csv.split('\n'); + expect(header).toBe( + 'Reference,Name,Email,Phone,Ticket Type,Status,Checked In,Purchased At', + ); + expect(row).toBe( + 'HOSTIT_TKT_A3F2B9C1,"Doe, Jane ""JD""",jane@example.com,,VIP,USED,2026-05-15T09:30:00.000Z,2026-04-05T10:00:00.000Z', + ); + }); +}); diff --git a/src/organizer/organizer.service.ts b/src/organizer/organizer.service.ts index 462973c..7cef697 100644 --- a/src/organizer/organizer.service.ts +++ b/src/organizer/organizer.service.ts @@ -20,6 +20,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { EnableMonnifyDto } from './dto/enable-monnify.dto'; import { EnablePaystackDto } from './dto/enable-paystack.dto'; import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; +import { QueryAttendeesDto } from './dto/query-attendees.dto'; /** Ticket statuses that count as a completed sale. */ const SOLD_STATUSES: TicketStatus[] = [ @@ -32,6 +33,11 @@ function dayKey(d: Date): string { return d.toISOString().slice(0, 10); } +/** RFC-4180 CSV field escaping. */ +function csvEscape(value: string): string { + return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; +} + /** Inclusive list of UTC day keys from `from` to `to`. */ function eachDay(from: Date, to: Date): string[] { const out: string[] = []; @@ -377,6 +383,165 @@ export class OrganizerService { }; } + /** + * Paginated attendee roster for an event (owner/admin), with optional + * status / ticket-type / name-or-email-search filters. The summary + * counts are event-wide (independent of the list filters). + */ + async getAttendees( + eventId: string, + query: QueryAttendeesDto, + actor: { id: string; role: UserRole }, + ) { + await this.assertEventAccess(eventId, actor); + + const where: Prisma.TicketWhereInput = { eventId }; + if (query.status) { + where.status = query.status; + } + if (query.ticketTypeId) { + where.ticketTypeId = query.ticketTypeId; + } + if (query.search) { + where.OR = [ + { buyerName: { contains: query.search, mode: 'insensitive' } }, + { buyerEmail: { contains: query.search, mode: 'insensitive' } }, + ]; + } + + const [rows, total, statusGroups] = await Promise.all([ + this.prisma.ticket.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: query.skip, + take: query.limit, + select: { + reference: true, + buyerName: true, + buyerEmail: true, + buyerPhone: true, + status: true, + checkedInAt: true, + createdAt: true, + ticketType: { select: { name: true } }, + }, + }), + this.prisma.ticket.count({ where }), + this.prisma.ticket.groupBy({ + by: ['status'], + where: { eventId }, + _count: { _all: true }, + }), + ]); + + const countByStatus = new Map( + statusGroups.map((g) => [g.status, g._count._all] as const), + ); + const totalAttendees = [...countByStatus.values()].reduce( + (a, b) => a + b, + 0, + ); + + return { + attendees: rows.map((r) => ({ + ticketReference: r.reference, + buyerName: r.buyerName, + buyerEmail: r.buyerEmail, + buyerPhone: r.buyerPhone, + ticketType: r.ticketType.name, + status: r.status, + checkedInAt: r.checkedInAt, + purchasedAt: r.createdAt, + })), + summary: { + totalAttendees, + confirmed: countByStatus.get(TicketStatus.CONFIRMED) ?? 0, + checkedIn: countByStatus.get(TicketStatus.USED) ?? 0, + cancelled: countByStatus.get(TicketStatus.CANCELLED) ?? 0, + }, + pagination: { + page: query.page, + limit: query.limit, + total, + totalPages: Math.ceil(total / query.limit), + }, + }; + } + + /** + * Full (unpaginated) attendee export as a CSV string, plus a slug-based + * filename. Owner/admin only. + */ + async exportAttendeesCSV( + eventId: string, + actor: { id: string; role: UserRole }, + ): Promise<{ filename: string; csv: string }> { + const event = await this.assertEventAccess(eventId, actor); + + const rows = await this.prisma.ticket.findMany({ + where: { eventId }, + orderBy: { createdAt: 'desc' }, + select: { + reference: true, + buyerName: true, + buyerEmail: true, + buyerPhone: true, + status: true, + checkedInAt: true, + createdAt: true, + ticketType: { select: { name: true } }, + }, + }); + + const header = [ + 'Reference', + 'Name', + 'Email', + 'Phone', + 'Ticket Type', + 'Status', + 'Checked In', + 'Purchased At', + ]; + const lines = [header.join(',')]; + for (const r of rows) { + lines.push( + [ + r.reference, + r.buyerName, + r.buyerEmail, + r.buyerPhone ?? '', + r.ticketType.name, + r.status, + r.checkedInAt ? r.checkedInAt.toISOString() : '', + r.createdAt.toISOString(), + ] + .map(csvEscape) + .join(','), + ); + } + + return { filename: `${event.slug}-attendees.csv`, csv: lines.join('\n') }; + } + + /** Load an event and assert the caller owns it (or is an admin). */ + private async assertEventAccess( + eventId: string, + actor: { id: string; role: UserRole }, + ): Promise<{ id: string; slug: string }> { + const event = await this.prisma.event.findUnique({ + where: { id: eventId }, + select: { id: true, slug: true, organizerId: true }, + }); + if (!event) { + throw new NotFoundException('Event not found'); + } + if (actor.role !== UserRole.ADMIN && event.organizerId !== actor.id) { + throw new ForbiddenException('You do not have access to this event'); + } + return { id: event.id, slug: event.slug }; + } + async enablePaystack(userId: string, dto: EnablePaystackDto) { const { profile, user } = await this.loadOrganizer(userId);