Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/common/decorators/skip-transform.decorator.ts
Original file line number Diff line number Diff line change
@@ -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);
20 changes: 17 additions & 3 deletions src/common/interceptors/transform.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
success: boolean;
Expand All @@ -15,12 +17,24 @@ export interface SuccessResponse<T> {
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<
T,
SuccessResponse<T>
SuccessResponse<T> | T
> {
constructor(private readonly reflector: Reflector) {}

intercept(
_context: ExecutionContext,
context: ExecutionContext,
next: CallHandler,
): Observable<SuccessResponse<T>> {
): Observable<SuccessResponse<T> | T> {
// Routes marked @SkipTransform (e.g. file/CSV downloads) return their
// raw payload unwrapped.
const skip = this.reflector.getAllAndOverride<boolean>(SKIP_TRANSFORM, [
context.getHandler(),
context.getClass(),
]);
if (skip) {
return next.handle();
}

return next.handle().pipe(
map((data) => ({
success: true,
Expand Down
11 changes: 5 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -78,7 +77,7 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(
new LoggingInterceptor(),
new TransformInterceptor(),
new TransformInterceptor(app.get(Reflector)),
);

// Swagger
Expand Down
38 changes: 38 additions & 0 deletions src/organizer/dto/query-attendees.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions src/organizer/dto/query-organizer-events.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
71 changes: 70 additions & 1 deletion src/organizer/organizer.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
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';

/**
Expand All @@ -21,6 +35,61 @@ 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);
}

@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);
}

@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<string> {
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)
Expand Down
Loading
Loading