This commit is contained in:
Executable
+128
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
HttpCode,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiOkResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiBadRequestResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { BaseService } from './base.service';
|
||||
import { PaginationDto } from '../dto/pagination.dto';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
createPaginatedResponse,
|
||||
} from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Generic base controller with common CRUD endpoints
|
||||
* Extend this class for entity-specific controllers
|
||||
*
|
||||
* Note: Use decorators like @Controller() on the child class
|
||||
*/
|
||||
export abstract class BaseController<T, CreateDto, UpdateDto> {
|
||||
constructor(
|
||||
protected readonly service: BaseService<T, CreateDto, UpdateDto>,
|
||||
protected readonly entityName: string,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Get all records with pagination' })
|
||||
@ApiOkResponse({ description: 'Records retrieved successfully' })
|
||||
async findAll(
|
||||
@Query() pagination: PaginationDto,
|
||||
): Promise<ApiResponse<{ items: T[]; meta: any }>> {
|
||||
const result = await this.service.findAll(pagination);
|
||||
return createPaginatedResponse(
|
||||
result.items,
|
||||
result.meta.total,
|
||||
result.meta.page,
|
||||
result.meta.limit,
|
||||
`${this.entityName} list retrieved successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Get a record by ID' })
|
||||
@ApiOkResponse({ description: 'Record retrieved successfully' })
|
||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.findOne(id);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} retrieved successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Create a new record' })
|
||||
@ApiOkResponse({ description: 'Record created successfully' })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
async create(@Body() createDto: CreateDto): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.create(createDto);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} created successfully`,
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Update an existing record' })
|
||||
@ApiOkResponse({ description: 'Record updated successfully' })
|
||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateDto: UpdateDto,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.update(id, updateDto);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} updated successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Delete a record (soft delete)' })
|
||||
@ApiOkResponse({ description: 'Record deleted successfully' })
|
||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
||||
async delete(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.delete(id);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} deleted successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Restore a soft-deleted record' })
|
||||
@ApiOkResponse({ description: 'Record restored successfully' })
|
||||
async restore(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.restore(id);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} restored successfully`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
import { NotFoundException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { PaginationDto } from '../dto/pagination.dto';
|
||||
import { PaginationMeta } from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Generic base service with common CRUD operations
|
||||
* Extend this class for entity-specific services
|
||||
*/
|
||||
export abstract class BaseService<T, CreateDto, UpdateDto> {
|
||||
protected readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
protected readonly prisma: PrismaService,
|
||||
protected readonly modelName: string,
|
||||
) {
|
||||
this.logger = new Logger(`${modelName}Service`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Prisma model delegate
|
||||
*/
|
||||
protected get model() {
|
||||
return (this.prisma as any)[this.modelName.toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all records with pagination
|
||||
*/
|
||||
async findAll(
|
||||
pagination: PaginationDto,
|
||||
where?: any,
|
||||
): Promise<{ items: T[]; meta: PaginationMeta }> {
|
||||
const { skip, take, orderBy } = pagination;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.model.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy,
|
||||
}),
|
||||
this.model.count({ where }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(total / take);
|
||||
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
total,
|
||||
page: pagination.page || 1,
|
||||
limit: pagination.limit || 10,
|
||||
totalPages,
|
||||
hasNextPage: (pagination.page || 1) < totalPages,
|
||||
hasPreviousPage: (pagination.page || 1) > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record by ID
|
||||
*/
|
||||
async findOne(id: string, include?: any): Promise<T> {
|
||||
const record = await this.model.findUnique({
|
||||
where: { id },
|
||||
include,
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException(`${this.modelName} not found`);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record by custom criteria
|
||||
*/
|
||||
findOneBy(where: any, include?: any): Promise<T | null> {
|
||||
return this.model.findFirst({
|
||||
where,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
*/
|
||||
create(data: CreateDto, include?: any): Promise<T> {
|
||||
return this.model.create({
|
||||
data,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing record
|
||||
*/
|
||||
async update(id: string, data: UpdateDto, include?: any): Promise<T> {
|
||||
// Check if record exists
|
||||
await this.findOne(id);
|
||||
|
||||
return this.model.update({
|
||||
where: { id },
|
||||
data,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a record (sets deletedAt)
|
||||
*/
|
||||
async delete(id: string): Promise<T> {
|
||||
// Check if record exists
|
||||
await this.findOne(id);
|
||||
|
||||
return this.model.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete a record (permanently removes)
|
||||
*/
|
||||
async hardDelete(id: string): Promise<T> {
|
||||
// Check if record exists
|
||||
await this.findOne(id);
|
||||
|
||||
return this.prisma.hardDelete(this.modelName, { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft-deleted record
|
||||
*/
|
||||
async restore(id: string): Promise<T> {
|
||||
return this.prisma.restore(this.modelName, { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a record exists
|
||||
*/
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const count = await this.model.count({
|
||||
where: { id },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count records matching criteria
|
||||
*/
|
||||
count(where?: any): Promise<number> {
|
||||
return this.model.count({ where });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction
|
||||
*/
|
||||
transaction<R>(fn: (prisma: PrismaService) => Promise<R>): Promise<R> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
return fn(tx as unknown as PrismaService);
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export * from './base.service';
|
||||
export * from './base.controller';
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Get the current authenticated user from request
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (data) {
|
||||
return user?.[data];
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Mark a route as public (no authentication required)
|
||||
*/
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
/**
|
||||
* Require specific roles to access a route
|
||||
*/
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
/**
|
||||
* Require specific permissions to access a route
|
||||
*/
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const RequirePermissions = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
/**
|
||||
* Get tenant ID from request (for multi-tenancy)
|
||||
*/
|
||||
export const CurrentTenant = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.tenantId;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the current language from request headers
|
||||
*/
|
||||
export const CurrentLang = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.headers['accept-language'] || 'en';
|
||||
},
|
||||
);
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
import { IsOptional, IsInt, Min, Max, IsString, IsIn } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PaginationDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1, description: 'Page number' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: 10,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description: 'Items per page',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 10;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Field to sort by' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: string = 'createdAt';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['asc', 'desc'],
|
||||
default: 'desc',
|
||||
description: 'Sort order',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
sortOrder?: 'asc' | 'desc' = 'desc';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Search query' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
/**
|
||||
* Get skip value for Prisma
|
||||
*/
|
||||
get skip(): number {
|
||||
return ((this.page || 1) - 1) * (this.limit || 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get take value for Prisma
|
||||
*/
|
||||
get take(): number {
|
||||
return this.limit || 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orderBy object for Prisma
|
||||
*/
|
||||
get orderBy(): Record<string, 'asc' | 'desc'> {
|
||||
return { [this.sortBy || 'createdAt']: this.sortOrder || 'desc' };
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { I18nService, I18nContext } from 'nestjs-i18n';
|
||||
import { ApiResponse, createErrorResponse } from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Global exception filter that catches all exceptions
|
||||
* and returns a standardized ApiResponse with HTTP 200
|
||||
*/
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
constructor(private readonly i18n?: I18nService) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
// Determine status and message
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let errors: string[] = [];
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object') {
|
||||
const responseObj = exceptionResponse as Record<string, unknown>;
|
||||
message = (responseObj.message as string) || exception.message;
|
||||
|
||||
// Handle validation errors (class-validator)
|
||||
if (Array.isArray(responseObj.message)) {
|
||||
errors = responseObj.message as string[];
|
||||
message = 'VALIDATION_FAILED';
|
||||
}
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
// Try to translate the message
|
||||
if (this.i18n) {
|
||||
try {
|
||||
const i18nContext = I18nContext.current();
|
||||
let lang = i18nContext?.lang;
|
||||
|
||||
if (!lang) {
|
||||
const acceptLanguage = request.headers['accept-language'];
|
||||
const xLang = request.headers['x-lang'];
|
||||
|
||||
if (xLang) {
|
||||
lang = Array.isArray(xLang) ? xLang[0] : xLang;
|
||||
} else if (acceptLanguage) {
|
||||
// Take first preferred language: "tr-TR,en;q=0.9" -> "tr"
|
||||
lang = acceptLanguage.split(',')[0].split(';')[0].split('-')[0];
|
||||
}
|
||||
}
|
||||
|
||||
lang = lang || 'en';
|
||||
|
||||
// Translate validation error specially
|
||||
if (message === 'VALIDATION_FAILED') {
|
||||
message = this.i18n.translate('errors.VALIDATION_FAILED', { lang });
|
||||
} else {
|
||||
// Try dynamic translation
|
||||
const translatedMessage = this.i18n.translate(`errors.${message}`, {
|
||||
lang,
|
||||
});
|
||||
// Only update if translation exists (key is different from result)
|
||||
if (translatedMessage !== `errors.${message}`) {
|
||||
message = translatedMessage;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep original message if translation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Log the error
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} - ${status} - ${message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
|
||||
// Build response
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
const errorResponse: ApiResponse<null> = createErrorResponse(
|
||||
message,
|
||||
status,
|
||||
errors,
|
||||
isDevelopment && exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
|
||||
// Always return HTTP 200, actual status in body
|
||||
response.status(200).json(errorResponse);
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiResponse, createSuccessResponse } from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Response interceptor that wraps all successful responses
|
||||
* in the standard ApiResponse format
|
||||
*/
|
||||
import { I18nService, I18nContext } from 'nestjs-i18n';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
ApiResponse<T>
|
||||
> {
|
||||
constructor(private readonly i18n: I18nService) {}
|
||||
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data: unknown) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// Determine language
|
||||
const i18nContext = I18nContext.current();
|
||||
let lang = i18nContext?.lang;
|
||||
|
||||
if (!lang) {
|
||||
const acceptLanguage = request.headers['accept-language'];
|
||||
const xLang = request.headers['x-lang'];
|
||||
|
||||
if (xLang) {
|
||||
lang = Array.isArray(xLang) ? xLang[0] : xLang;
|
||||
} else if (acceptLanguage) {
|
||||
lang = acceptLanguage.split(',')[0].split(';')[0].split('-')[0];
|
||||
}
|
||||
}
|
||||
|
||||
lang = lang || 'en';
|
||||
|
||||
// If data is already an ApiResponse, we should still translate its 'data' property
|
||||
// But first let's just do it directly on 'data' below before returning
|
||||
if (this.isApiResponse(data)) {
|
||||
if (data !== null) {
|
||||
try {
|
||||
this.translateReasons(data, lang);
|
||||
} catch {
|
||||
// Ignore if object is not extensible
|
||||
}
|
||||
}
|
||||
return data as ApiResponse<T>;
|
||||
}
|
||||
|
||||
// Recursively translate reasons arrays in the response body
|
||||
if (data !== null) {
|
||||
try {
|
||||
this.translateReasons(data, lang);
|
||||
} catch {
|
||||
// Ignore if object is not extensible
|
||||
}
|
||||
}
|
||||
|
||||
const message = this.i18n.translate('common.success', {
|
||||
lang,
|
||||
});
|
||||
|
||||
// Wrap in success response
|
||||
return createSuccessResponse(data as T, message);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private translateReasons(data: any, lang: string) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((item) => this.translateReasons(item, lang));
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(data).forEach((key) => {
|
||||
const val = data[key];
|
||||
if (
|
||||
(key === 'reasons' ||
|
||||
key === 'decision_reasons' ||
|
||||
key === 'reasoning_factors') &&
|
||||
Array.isArray(val)
|
||||
) {
|
||||
data[key] = val.map((r: any) => {
|
||||
if (typeof r !== 'string') return r;
|
||||
const translationKey = `predictions.reasons.${r}`;
|
||||
const translated = this.i18n.translate(translationKey, {
|
||||
lang,
|
||||
});
|
||||
return translated === translationKey ? r : translated;
|
||||
});
|
||||
} else if (key === 'reason' && typeof val === 'string') {
|
||||
const translationKey = `predictions.reasons.${val}`;
|
||||
const translated = this.i18n.translate(translationKey, {
|
||||
lang,
|
||||
});
|
||||
data[key] = translated === translationKey ? val : translated;
|
||||
} else if (key === 'flags' && Array.isArray(val)) {
|
||||
data[key] = val.map((r: any) => {
|
||||
if (typeof r !== 'string') return r;
|
||||
const translationKey = `predictions.flags.${r}`;
|
||||
const translated = this.i18n.translate(translationKey, {
|
||||
lang,
|
||||
});
|
||||
return translated === translationKey ? r : translated;
|
||||
});
|
||||
} else if (key === 'warnings' && Array.isArray(val)) {
|
||||
data[key] = val.map((r: any) => {
|
||||
if (typeof r !== 'string') return r;
|
||||
const translationKey = `predictions.warnings.${r}`;
|
||||
const translated = this.i18n.translate(translationKey, {
|
||||
lang,
|
||||
});
|
||||
return translated === translationKey ? r : translated;
|
||||
});
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
this.translateReasons(val, lang);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private isApiResponse(data: unknown): boolean {
|
||||
return (
|
||||
data !== null &&
|
||||
typeof data === 'object' &&
|
||||
'success' in data &&
|
||||
'status' in data &&
|
||||
'message' in data &&
|
||||
'data' in data
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Strips HTML/script tags from all string values in the request body.
|
||||
* Applied globally to prevent stored XSS via API inputs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SanitizeInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
if (request.body && typeof request.body === 'object') {
|
||||
request.body = this.sanitize(request.body);
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
private sanitize(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return this.stripTags(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.sanitize(item));
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
sanitized[key] = this.sanitize(val);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private stripTags(input: string): string {
|
||||
return input.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
}
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
connection: {
|
||||
host: configService.get('redis.host', 'localhost'),
|
||||
port: configService.get('redis.port', 6379),
|
||||
password: configService.get('redis.password'),
|
||||
},
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 1000,
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
exports: [BullModule],
|
||||
})
|
||||
export class QueueModule {}
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Standard API Response Type
|
||||
* All responses return HTTP 200 with this structure
|
||||
*/
|
||||
export type ApiResponse<T = unknown> = {
|
||||
errors: unknown[];
|
||||
stack?: string;
|
||||
message: string;
|
||||
success: boolean;
|
||||
status: number;
|
||||
data: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Paginated response wrapper
|
||||
*/
|
||||
export interface PaginatedData<T> {
|
||||
items: T[];
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a successful API response
|
||||
*/
|
||||
export function createSuccessResponse<T>(
|
||||
data: T,
|
||||
message = 'Success',
|
||||
status = 200,
|
||||
): ApiResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
status,
|
||||
message,
|
||||
data,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error API response
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
message: string,
|
||||
status = 400,
|
||||
errors: any[] = [],
|
||||
stack?: string,
|
||||
): ApiResponse<null> {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
message,
|
||||
data: null,
|
||||
errors,
|
||||
stack,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a paginated API response
|
||||
*/
|
||||
export function createPaginatedResponse<T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
message = 'Success',
|
||||
): ApiResponse<PaginatedData<T>> {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: 200,
|
||||
message,
|
||||
data: {
|
||||
items,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
import { existsSync, createWriteStream, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import axios from 'axios';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
export class ImageUtils {
|
||||
private static readonly logger = new Logger('ImageUtils');
|
||||
|
||||
/**
|
||||
* Downloads an image from a URL and saves it to a local path.
|
||||
* Skips download if file already exists.
|
||||
*/
|
||||
static async downloadImage(url: string, localPath: string): Promise<boolean> {
|
||||
try {
|
||||
// Check if file exists
|
||||
if (existsSync(localPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = dirname(localPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Download
|
||||
const response = await axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
timeout: 5000,
|
||||
validateStatus: (status) => status === 200, // Only save if 200 OK
|
||||
});
|
||||
|
||||
const writer = createWriteStream(localPath);
|
||||
|
||||
response.data.pipe(writer);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', () => resolve(true));
|
||||
writer.on('error', (err) => {
|
||||
this.logger.warn(
|
||||
`Failed to write image to ${localPath}: ${err.message}`,
|
||||
);
|
||||
reject(new Error(`Failed to write image to ${localPath}`));
|
||||
});
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Log warning but don't break the application
|
||||
// 404s are common for missing logos
|
||||
if (error.response?.status !== 404) {
|
||||
this.logger.warn(
|
||||
`Failed to download image from ${url}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user