Skip to content
7 changes: 3 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ FROM node:22-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/prisma ./prisma
RUN chown -R appuser:appgroup /app
COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=build --chown=appuser:appgroup /app/dist ./dist
COPY --from=build --chown=appuser:appgroup /app/prisma ./prisma
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,10 @@ is safe. It creates:
- **alice@example.com** (attendee) and **organiser@example.com** (organiser),
both with password `Password123!`
- A demo organisation **Stellar Events Demo**
- A draft event **StellarFest 2027** with General Admission and VIP ticket types
- A published event **StellarFest 2027** with General Admission and VIP ticket types

The production Docker image runs as the unprivileged `appuser` account rather
than root.

Seed data can be wiped and recreated with:

Expand Down
7 changes: 7 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# API reference

## Interactive OpenAPI documentation

When `NODE_ENV` is not `production`, Swagger UI is available at `/docs` and
the generated OpenAPI document at `/docs-json`. The schema is generated from
controllers and DTOs at startup. Swagger is disabled in production so the
interactive schema is not exposed publicly.

Every route below is real: paths come from the controllers, request
bodies from the `class-validator` DTOs, and response bodies from what the
services return. When they drift, the generated Bruno collection under
Expand Down
29 changes: 22 additions & 7 deletions docs/ERROR_HANDLING.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
# Error handling

Nest's built-in HTTP exception filter handles everything — services
throw `NotFoundException`, `ForbiddenException`, `ConflictException`,
`BadRequestException`, etc. from `@nestjs/common`, and Nest serializes
them to `{ statusCode, message, error }` automatically.
`GlobalExceptionFilter` is registered in `src/main.ts` and handles every
uncaught exception at the HTTP boundary. Known Nest HTTP exceptions retain
Nest's `{ statusCode, message, error }` response shape. Unexpected errors are
sanitised and returned as:

```json
{
"statusCode": 500,
"code": "INTERNAL_ERROR",
"message": "Internal server error",
"error": "Internal Server Error",
"requestId": "<request id>"
}
```

The request id is taken from `X-Request-Id` when supplied; otherwise the
filter generates one. Unexpected errors are logged server-side with the
request method and URL, but their internal message and stack are never
exposed to callers.

`ValidationPipe` (registered globally in `main.ts`) rejects any
request body that doesn't match its DTO's `class-validator` decorators
before the request ever reaches a controller method.

There's no custom global exception filter yet — see
`docs/OBSERVABILITY.md` for what's still missing before a production
deploy (structured error logging in particular).
Prisma and domain errors are mapped to stable status codes and public error
messages by the same filter. See `docs/OBSERVABILITY.md` for production
monitoring guidance.
16 changes: 14 additions & 2 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* prisma/seed.ts — local development seed
*
* Creates a demo user, organiser, event and two ticket types so new
* Creates a demo user, organiser, published event and two ticket types so new
* contributors have a populated DB immediately after running
* `npx prisma migrate dev`.
*
Expand All @@ -12,7 +12,13 @@
* running the script more than once is safe.
*/

import { PrismaClient, Industry, UserRole, OrgMemberRole } from '@prisma/client';
import {
PrismaClient,
EventStatus,
Industry,
UserRole,
OrgMemberRole,
} from '@prisma/client';
import * as bcrypt from 'bcrypt';

const prisma = new PrismaClient();
Expand Down Expand Up @@ -89,12 +95,18 @@ async function main() {
description: 'A demo concert powered by StellarTickets.',
imageUrl: null,
category: Industry.CONCERTS,
status: EventStatus.PUBLISHED,
venue: 'The Stellar Arena, Cape Town',
timezone: 'Africa/Johannesburg',
startsAt: new Date('2027-03-15T19:00:00Z'),
endsAt: new Date('2027-03-15T23:00:00Z'),
},
});
} else if (event.status !== EventStatus.PUBLISHED) {
event = await prisma.event.update({
where: { id: event.id },
data: { status: EventStatus.PUBLISHED },
});
}

// ── 6. Ticket types ──────────────────────────────────────────────────────
Expand Down
7 changes: 7 additions & 0 deletions src/common/filters/global-exception.filter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('GlobalExceptionFilter', () => {
const request = {
method: 'GET',
url: '/test',
headers: { 'x-request-id': 'req-test-1' },
};
const argumentsHost = {
switchToHttp: () => ({
Expand Down Expand Up @@ -216,6 +217,8 @@ describe('GlobalExceptionFilter', () => {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
code: 'INTERNAL_ERROR',
message: 'Internal server error',
error: 'Internal Server Error',
requestId: 'req-test-1',
});
});
});
Expand All @@ -233,6 +236,8 @@ describe('GlobalExceptionFilter', () => {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
code: 'INTERNAL_ERROR',
message: 'Internal server error',
error: 'Internal Server Error',
requestId: 'req-test-1',
});
});

Expand All @@ -248,6 +253,8 @@ describe('GlobalExceptionFilter', () => {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
code: 'INTERNAL_ERROR',
message: 'Internal server error',
error: 'Internal Server Error',
requestId: 'req-test-1',
});
});
});
Expand Down
19 changes: 19 additions & 0 deletions src/common/filters/global-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Logger,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { Prisma } from '@prisma/client';
import { DomainError } from '../errors/domain.error';

Expand Down Expand Up @@ -94,16 +95,34 @@
);
}

fix/issues-395-396-397-398-runtime-hardening

Check failure on line 98 in src/common/filters/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'hardening'.

Check failure on line 98 in src/common/filters/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'runtime'.

Check failure on line 98 in src/common/filters/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'issues'.

Check failure on line 98 in src/common/filters/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'fix'.
const responseBody = {
statusCode,
code,
message,
...(code === 'INTERNAL_ERROR' && {
error: 'Internal Server Error',
requestId: this.getRequestId(request),
}),
};
response.status(statusCode).json(responseBody);
}

private getRequestId(request: Request): string {
const header = request.headers['x-request-id'];
return (Array.isArray(header) ? header[0] : header) ?? randomUUID();

const errorBody: Record<string, unknown> = {
statusCode,

Check failure on line 116 in src/common/filters/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / test

No value exists in scope for the shorthand property 'statusCode'. Either declare one or provide an initializer.
code,

Check failure on line 117 in src/common/filters/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / test

No value exists in scope for the shorthand property 'code'. Either declare one or provide an initializer.
message,

Check failure on line 118 in src/common/filters/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'message'. Did you mean 'onmessage'?
};
if (requestId) {

Check failure on line 120 in src/common/filters/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'requestId'. Did you mean 'request'?
errorBody.requestId = requestId;

Check failure on line 121 in src/common/filters/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'requestId'. Did you mean 'request'?
}

response.status(statusCode).json(errorBody);

Check failure on line 124 in src/common/filters/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'response'. Did you mean 'Response'?
main
}

private getPrismaErrorMessage(
Expand Down
4 changes: 3 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ async function bootstrap() {
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, document);
SwaggerModule.setup('docs', app, document, {
jsonDocumentUrl: 'docs-json',
});
}

await app.listen(config.getOrThrow<number>('PORT'));
Expand Down
Loading