first commit
هذا الالتزام موجود في:
6
src/common/decorators/current-user.decorator.ts
Normal file
6
src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator((_: unknown, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
});
|
||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = (): ReturnType<typeof SetMetadata> => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
5
src/common/decorators/roles.decorator.ts
Normal file
5
src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]): ReturnType<typeof SetMetadata> =>
|
||||
SetMetadata(ROLES_KEY, roles);
|
||||
11
src/common/decorators/throttle.decorator.ts
Normal file
11
src/common/decorators/throttle.decorator.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const THROTTLE_META_KEY = 'throttle_meta_key';
|
||||
|
||||
export type ThrottleMeta = {
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
};
|
||||
|
||||
export const Throttle = (limit: number, windowMs: number) =>
|
||||
SetMetadata(THROTTLE_META_KEY, { limit, windowMs } satisfies ThrottleMeta);
|
||||
6
src/common/dto/object-id-param.dto.ts
Normal file
6
src/common/dto/object-id-param.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsMongoId } from 'class-validator';
|
||||
|
||||
export class ObjectIdParamDto {
|
||||
@IsMongoId()
|
||||
id!: string;
|
||||
}
|
||||
22
src/common/dto/pagination-query.dto.ts
Normal file
22
src/common/dto/pagination-query.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { APP_CONSTANTS } from '../../config/constants';
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = APP_CONSTANTS.DEFAULT_PAGE;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(APP_CONSTANTS.MAX_LIMIT)
|
||||
limit?: number = APP_CONSTANTS.DEFAULT_LIMIT;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cursor?: string;
|
||||
}
|
||||
6
src/common/enums/experience-level.enum.ts
Normal file
6
src/common/enums/experience-level.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum ExperienceLevel {
|
||||
BEGINNER = 'beginner',
|
||||
INTERMEDIATE = 'intermediate',
|
||||
ADVANCED = 'advanced',
|
||||
PROFESSIONAL = 'professional',
|
||||
}
|
||||
8
src/common/enums/music-role.enum.ts
Normal file
8
src/common/enums/music-role.enum.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum MusicRole {
|
||||
INSTRUMENTALIST = 'instrumentalist',
|
||||
SINGER = 'singer',
|
||||
COMPOSER = 'composer',
|
||||
LYRICIST = 'lyricist',
|
||||
PRODUCER = 'producer',
|
||||
ARRANGER = 'arranger',
|
||||
}
|
||||
6
src/common/enums/notification-type.enum.ts
Normal file
6
src/common/enums/notification-type.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum NotificationType {
|
||||
LIKE = 'like',
|
||||
COMMENT = 'comment',
|
||||
FOLLOW = 'follow',
|
||||
MESSAGE = 'message',
|
||||
}
|
||||
5
src/common/enums/post-type.enum.ts
Normal file
5
src/common/enums/post-type.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum PostType {
|
||||
TEXT = 'text',
|
||||
VIDEO = 'video',
|
||||
AUDIO = 'audio',
|
||||
}
|
||||
5
src/common/enums/post-visibility.enum.ts
Normal file
5
src/common/enums/post-visibility.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum PostVisibility {
|
||||
PUBLIC = 'public',
|
||||
FOLLOWERS = 'followers',
|
||||
PRIVATE = 'private',
|
||||
}
|
||||
4
src/common/enums/token-type.enum.ts
Normal file
4
src/common/enums/token-type.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum TokenType {
|
||||
ACCESS = 'access',
|
||||
REFRESH = 'refresh',
|
||||
}
|
||||
5
src/common/enums/user-role.enum.ts
Normal file
5
src/common/enums/user-role.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum UserRole {
|
||||
USER = 'user',
|
||||
ADMIN = 'admin',
|
||||
SUPERADMIN = 'superadmin',
|
||||
}
|
||||
5
src/common/guards/jwt-auth.guard.ts
Normal file
5
src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
5
src/common/guards/jwt-refresh.guard.ts
Normal file
5
src/common/guards/jwt-refresh.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
|
||||
29
src/common/guards/roles.guard.ts
Normal file
29
src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const payload = request.user ?? {};
|
||||
const userRoles: string[] = Array.isArray(payload.roles)
|
||||
? payload.roles
|
||||
: payload.role
|
||||
? [payload.role]
|
||||
: [];
|
||||
|
||||
return requiredRoles.some((role) => userRoles.includes(role));
|
||||
}
|
||||
}
|
||||
5
src/common/guards/super-admin-jwt-auth.guard.ts
Normal file
5
src/common/guards/super-admin-jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class SuperAdminJwtAuthGuard extends AuthGuard('superadmin-jwt') {}
|
||||
50
src/common/guards/throttle.guard.ts
Normal file
50
src/common/guards/throttle.guard.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { THROTTLE_META_KEY, ThrottleMeta } from '../decorators/throttle.decorator';
|
||||
|
||||
type Bucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ThrottleGuard implements CanActivate {
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const meta = this.reflector.getAllAndOverride<ThrottleMeta>(THROTTLE_META_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!meta) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request & { ip?: string; originalUrl?: string }>();
|
||||
const ip = req.ip ?? 'unknown';
|
||||
const route = req.originalUrl ?? 'unknown-route';
|
||||
const key = `${ip}:${route}`;
|
||||
const now = Date.now();
|
||||
const existing = this.buckets.get(key);
|
||||
|
||||
if (!existing || now > existing.resetAt) {
|
||||
this.buckets.set(key, { count: 1, resetAt: now + meta.windowMs });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existing.count >= meta.limit) {
|
||||
throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
25
src/common/interceptors/response-envelope.interceptor.ts
Normal file
25
src/common/interceptors/response-envelope.interceptor.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { map, Observable } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseEnvelopeInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const response = http.getResponse<{ statusCode?: number }>();
|
||||
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
data,
|
||||
meta: {
|
||||
statusCode: response.statusCode ?? 200,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
7
src/common/interfaces/jwt-payload.interface.ts
Normal file
7
src/common/interfaces/jwt-payload.interface.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
role?: string;
|
||||
tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh';
|
||||
email?: string;
|
||||
}
|
||||
13
src/common/utils/cursor.util.spec.ts
Normal file
13
src/common/utils/cursor.util.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { decodeOffsetCursor, encodeOffsetCursor } from './cursor.util';
|
||||
|
||||
describe('cursor util', () => {
|
||||
it('encodes and decodes cursor offsets', () => {
|
||||
const cursor = encodeOffsetCursor(40);
|
||||
expect(decodeOffsetCursor(cursor)).toBe(40);
|
||||
});
|
||||
|
||||
it('returns null on invalid cursor', () => {
|
||||
expect(decodeOffsetCursor('%%%invalid%%%')).toBeNull();
|
||||
expect(decodeOffsetCursor(encodeOffsetCursor(-1))).toBeNull();
|
||||
});
|
||||
});
|
||||
19
src/common/utils/cursor.util.ts
Normal file
19
src/common/utils/cursor.util.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const encodeOffsetCursor = (offset: number): string =>
|
||||
Buffer.from(String(offset), 'utf8').toString('base64url');
|
||||
|
||||
export const decodeOffsetCursor = (cursor?: string): number | null => {
|
||||
if (!cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = Buffer.from(cursor, 'base64url').toString('utf8');
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
7
src/common/utils/hash.util.ts
Normal file
7
src/common/utils/hash.util.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
export const hashValue = async (value: string, saltRounds: number): Promise<string> =>
|
||||
bcrypt.hash(value, saltRounds);
|
||||
|
||||
export const compareHash = async (value: string, hashedValue: string): Promise<boolean> =>
|
||||
bcrypt.compare(value, hashedValue);
|
||||
المرجع في مشكلة جديدة
حظر مستخدم