main
Some checks failed
CI / build (push) Failing after 1m58s

This commit is contained in:
2026-01-26 23:22:38 +03:00
commit 6ef44f398d
110 changed files with 23268 additions and 0 deletions

View File

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

View File

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

2
src/common/base/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './base.service';
export * from './base.controller';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
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) => {
// If data is already an ApiResponse, return as-is
if (this.isApiResponse(data)) {
return data as ApiResponse<T>;
}
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';
const message = this.i18n.translate('common.success', {
lang,
});
// Wrap in success response
return createSuccessResponse(data as T, message);
}),
);
}
private isApiResponse(data: unknown): boolean {
return (
data !== null &&
typeof data === 'object' &&
'success' in data &&
'status' in data &&
'message' in data &&
'data' in data
);
}
}

View File

@@ -0,0 +1,96 @@
/**
* Standard API Response Type
* All responses return HTTP 200 with this structure
*/
export type ApiResponse<T = any> = {
errors: any[];
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: [],
};
}