Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"fast-csv": "^5.0.2",
"geoip-lite": "^1.4.10",
"google-auth-library": "^9.15.1",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2",
Expand Down Expand Up @@ -66,6 +67,7 @@
"@swc/core": "^1.10.7",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0",
"@types/geoip-lite": "^1.4.4",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.10.7",
Expand Down
5 changes: 5 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { JwtAuthModule, JwtAuthMiddleware } from './auth/middleware/jwt-auth.mod
import { REDIS_CLIENT } from './redis/redis.constants';
import jwtConfig from './auth/authConfig/jwt.config';
import { UsersService } from './users/providers/users.service';
import { GeolocationMiddleware } from './common/middleware/geolocation.middleware';
import { HealthModule } from './health/health.module';

// const ENV = process.env.NODE_ENV;
Expand Down Expand Up @@ -110,6 +111,10 @@ export class AppModule implements NestModule {
* Apply the JWT Authentication Middleware to all routes except public ones.
*/
configure(consumer: MiddlewareConsumer) {
consumer
.apply(GeolocationMiddleware)
.forRoutes('*');

consumer
.apply(JwtAuthMiddleware)
.exclude(
Expand Down
16 changes: 14 additions & 2 deletions backend/src/common/common.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { Module } from '@nestjs/common';
import { PaginationProvider } from './pagination/provider/pagination-provider';
import { CorrelationIdMiddleware } from './middleware/correlation-id.middleware';
import { GeolocationMiddleware } from './middleware/geolocation.middleware';
import { RedisModule } from '../redis/redis.module';

@Module({
providers: [PaginationProvider],
exports: [PaginationProvider],
imports: [RedisModule],
providers: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware],
exports: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware],
})
export class CommonModule {}

// Re-export public API so other modules can import from '@/common'
export * from './errors';
export * from './filters/http-exception.filter';
export * from './middleware/correlation-id.middleware';
export * from './middleware/geolocation.middleware';
export * from './interfaces/geolocation.interface';
export * from './decorators/geolocation.decorator';
15 changes: 15 additions & 0 deletions backend/src/common/decorators/geolocation.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GeolocationData } from '../interfaces/geolocation.interface';

export const LocationData = createParamDecorator(
(data: keyof GeolocationData | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const location = request.location;

if (!location) {
return null;
}

return data ? location[data] : location;
},
);
143 changes: 143 additions & 0 deletions backend/src/common/errors/app.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { AppErrorCode } from './error-codes.enum';

/**
* Optional per-field validation detail included in 400 responses.
*/
export interface ErrorDetail {
field?: string;
message: string;
value?: unknown;
}

/**
* Metadata passed when constructing an AppException.
*/
export interface AppExceptionOptions {
/** Machine-readable error code for frontend programmatic handling. */
errorCode: AppErrorCode;
/** HTTP status to send. Defaults to 500. */
status?: HttpStatus;
/** Human-readable message (may be overridden in production). */
message?: string;
/** Field-level validation details (visible to clients). */
details?: ErrorDetail[];
/** i18n translation key for localisation support. */
i18nKey?: string;
/** Extra context attached to logging ONLY – never sent to the client. */
context?: Record<string, unknown>;
}

/**
* Base typed exception.
*
* Throw any subclass (or this directly) and the `AllExceptionsFilter`
* will format the response consistently.
*/
export class AppException extends HttpException {
readonly errorCode: AppErrorCode;
readonly details: ErrorDetail[] | undefined;
readonly i18nKey: string | undefined;
/** Private metadata for the logger — never serialised to the response. */
readonly logContext: Record<string, unknown> | undefined;

constructor(options: AppExceptionOptions) {
const status = options.status ?? HttpStatus.INTERNAL_SERVER_ERROR;
const message = options.message ?? 'An unexpected error occurred';

super(message, status);

this.errorCode = options.errorCode;
this.details = options.details;
this.i18nKey = options.i18nKey;
this.logContext = options.context;
}
}

// ─── Convenience subclasses ──────────────────────────────────────────────────

export class ValidationException extends AppException {
constructor(details: ErrorDetail[], message = 'Validation failed') {
super({
errorCode: AppErrorCode.VALIDATION_FAILED,
status: HttpStatus.BAD_REQUEST,
message,
details,
i18nKey: 'errors.validation_failed',
});
}
}

export class AuthenticationException extends AppException {
constructor(
message = 'Authentication required',
errorCode: AppErrorCode = AppErrorCode.AUTH_INVALID_CREDENTIALS,
) {
super({
errorCode,
status: HttpStatus.UNAUTHORIZED,
message,
i18nKey: 'errors.authentication_required',
});
}
}

export class AuthorizationException extends AppException {
constructor(message = 'Insufficient permissions') {
super({
errorCode: AppErrorCode.INSUFFICIENT_PERMISSIONS,
status: HttpStatus.FORBIDDEN,
message,
i18nKey: 'errors.insufficient_permissions',
});
}
}

export class NotFoundException extends AppException {
constructor(resource = 'Resource', message?: string) {
super({
errorCode: AppErrorCode.RESOURCE_NOT_FOUND,
status: HttpStatus.NOT_FOUND,
message: message ?? `${resource} not found`,
i18nKey: 'errors.resource_not_found',
});
}
}

export class ConflictException extends AppException {
constructor(message = 'Resource already exists') {
super({
errorCode: AppErrorCode.DUPLICATE_RESOURCE,
status: HttpStatus.CONFLICT,
message,
i18nKey: 'errors.duplicate_resource',
});
}
}

export class RateLimitException extends AppException {
constructor(message = 'Too many requests — please slow down') {
super({
errorCode: AppErrorCode.RATE_LIMIT_EXCEEDED,
status: HttpStatus.TOO_MANY_REQUESTS,
message,
i18nKey: 'errors.rate_limit_exceeded',
});
}
}

export class DatabaseException extends AppException {
constructor(
message = 'A database error occurred',
errorCode: AppErrorCode = AppErrorCode.DB_QUERY_FAILED,
context?: Record<string, unknown>,
) {
super({
errorCode,
status: HttpStatus.INTERNAL_SERVER_ERROR,
message,
i18nKey: 'errors.database_error',
context,
});
}
}
40 changes: 40 additions & 0 deletions backend/src/common/errors/error-codes.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Centralized error codes for programmatic handling on the frontend.
* Every error returned by the API carries one of these codes so clients
* can react without string-matching on human-readable messages.
*/
export enum AppErrorCode {
// ── Authentication ──────────────────────────────────────────────────────────
AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED',
AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID',
AUTH_TOKEN_MISSING = 'AUTH_TOKEN_MISSING',
AUTH_TOKEN_BLACKLISTED = 'AUTH_TOKEN_BLACKLISTED',
AUTH_INVALID_CREDENTIALS = 'AUTH_INVALID_CREDENTIALS',
AUTH_USER_NOT_FOUND = 'AUTH_USER_NOT_FOUND',

// ── Authorization ────────────────────────────────────────────────────────────
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
ACCESS_DENIED = 'ACCESS_DENIED',

// ── Validation ───────────────────────────────────────────────────────────────
VALIDATION_FAILED = 'VALIDATION_FAILED',
INVALID_INPUT = 'INVALID_INPUT',

// ── Resource ─────────────────────────────────────────────────────────────────
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
DUPLICATE_RESOURCE = 'DUPLICATE_RESOURCE',
RESOURCE_CONFLICT = 'RESOURCE_CONFLICT',

// ── Rate Limiting ─────────────────────────────────────────────────────────────
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',

// ── Database ──────────────────────────────────────────────────────────────────
DB_CONNECTION_ERROR = 'DB_CONNECTION_ERROR',
DB_CONSTRAINT_VIOLATION = 'DB_CONSTRAINT_VIOLATION',
DB_QUERY_FAILED = 'DB_QUERY_FAILED',

// ── Generic ───────────────────────────────────────────────────────────────────
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
}
2 changes: 2 additions & 0 deletions backend/src/common/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './error-codes.enum';
export * from './app.exception';
Loading
Loading