Project Initialization

This commit is contained in:
Muluhabt
2026-05-12 15:17:16 +03:00
parent 33fa742e8a
commit 3b8b6979db
259 changed files with 15962 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
{
"name": "@edr/api-common",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint src"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"typeorm": "^0.3.20"
},
"dependencies": {
"@edr/types": "workspace:*"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@nestjs/common": "^10.4.6",
"@nestjs/core": "^10.4.6",
"@types/node": "^20.14.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,11 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
// TODO: integrate @edr/auth — this decorator currently returns whatever was
// attached to request.user by the (not-yet-wired) auth middleware. Once the
// auth package is integrated, replace this with the real user type.
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user ?? null;
},
);

View File

@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
// TODO: integrate @edr/auth — pair with the JwtAuthGuard from the auth package
// so it skips authentication for routes marked @Public(). Until then, this is
// just metadata.
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
// TODO: integrate @edr/auth — pair this metadata with a RolesGuard from the
// auth package. Until then, this decorator only sets metadata and has no
// runtime effect.
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,20 @@
import {
CreateDateColumn,
DeleteDateColumn,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export abstract class BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date | null;
}

View File

@@ -0,0 +1,58 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
interface ErrorResponseBody {
success: false;
statusCode: number;
message: string;
error?: string;
timestamp: string;
path: string;
}
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const messageRaw =
exception instanceof HttpException ? exception.getResponse() : 'Internal server error';
const message =
typeof messageRaw === 'string'
? messageRaw
: (messageRaw as { message?: string }).message ?? 'Unexpected error';
const body: ErrorResponseBody = {
success: false,
statusCode: status,
message,
error: exception instanceof Error ? exception.name : 'Error',
timestamp: new Date().toISOString(),
path: request.url,
};
if (status >= 500) {
this.logger.error(`${request.method} ${request.url} -> ${status}`, (exception as Error)?.stack);
} else {
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
}
response.status(status).json(body);
}
}

View File

@@ -0,0 +1,19 @@
// Decorators
export * from './decorators/current-user.decorator';
export * from './decorators/roles.decorator';
export * from './decorators/public.decorator';
// Filters
export * from './filters/http-exception.filter';
// Interceptors
export * from './interceptors/response-transform.interceptor';
// Pipes
export * from './pipes/validation.pipe';
// Entities
export * from './entities/base.entity';
// Repositories
export * from './repositories/base.repository';

View File

@@ -0,0 +1,22 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface StandardResponse<T> {
success: true;
data: T;
timestamp: string;
}
@Injectable()
export class ResponseTransformInterceptor<T> implements NestInterceptor<T, StandardResponse<T>> {
intercept(_context: ExecutionContext, next: CallHandler<T>): Observable<StandardResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@@ -0,0 +1,10 @@
import { ValidationPipe, ValidationPipeOptions } from '@nestjs/common';
export const createValidationPipe = (overrides?: ValidationPipeOptions): ValidationPipe =>
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
...overrides,
});

View File

@@ -0,0 +1,52 @@
import {
DeepPartial,
FindManyOptions,
FindOneOptions,
FindOptionsWhere,
ObjectLiteral,
Repository,
} from 'typeorm';
export abstract class BaseRepository<T extends ObjectLiteral> {
protected constructor(protected readonly repository: Repository<T>) {}
/** Find a single entity by its primary key. */
async findById(id: string, options?: Omit<FindOneOptions<T>, 'where'>): Promise<T | null> {
return this.repository.findOne({
...options,
where: { id } as unknown as FindOptionsWhere<T>,
});
}
/** Find many entities matching the given options. */
async findAll(options?: FindManyOptions<T>): Promise<T[]> {
return this.repository.find(options);
}
/** Find many entities + return the total count for pagination. */
async findAndCount(options?: FindManyOptions<T>): Promise<[T[], number]> {
return this.repository.findAndCount(options);
}
/** Create and persist a new entity. */
async create(data: DeepPartial<T>): Promise<T> {
const entity = this.repository.create(data);
return this.repository.save(entity);
}
/** Patch an entity in place and return the reloaded row. */
async update(id: string, data: DeepPartial<T>): Promise<T | null> {
await this.repository.update(id, data as never);
return this.findById(id);
}
/** Soft-delete an entity by primary key (sets deleted_at). */
async softDelete(id: string): Promise<void> {
await this.repository.softDelete(id);
}
/** Permanently delete an entity. Avoid in domain code; prefer softDelete. */
async hardDelete(id: string): Promise<void> {
await this.repository.delete(id);
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@edr/tsconfig/nestjs.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"noEmit": true
},
"include": ["src"]
}

File diff suppressed because one or more lines are too long