feat: expand backend admin marketplace and scaling
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s

هذا الالتزام موجود في:
2026-05-14 16:17:12 +03:00
الأصل 0e76a4a9fc
التزام 5bd5e19a89
158 ملفات معدلة مع 19563 إضافات و3315 حذوفات

عرض الملف

@@ -0,0 +1,7 @@
import { SetMetadata } from '@nestjs/common';
import { SuperAdminPermission } from '../../modules/superadmin/superadmin-permissions';
export const SUPERADMIN_PERMISSIONS_KEY = 'superadmin_permissions';
export const SuperAdminPermissions = (...permissions: SuperAdminPermission[]) =>
SetMetadata(SUPERADMIN_PERMISSIONS_KEY, permissions);

عرض الملف

@@ -1,14 +1,18 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { SortOrder } from '../enums/sort-order.enum';
import { APP_CONSTANTS } from '../../config/constants';
export class PaginationQueryDto {
@ApiPropertyOptional({ default: APP_CONSTANTS.DEFAULT_PAGE })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = APP_CONSTANTS.DEFAULT_PAGE;
@ApiPropertyOptional({ default: APP_CONSTANTS.DEFAULT_LIMIT, maximum: APP_CONSTANTS.MAX_LIMIT })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -16,7 +20,20 @@ export class PaginationQueryDto {
@Max(APP_CONSTANTS.MAX_LIMIT)
limit?: number = APP_CONSTANTS.DEFAULT_LIMIT;
@ApiPropertyOptional({ description: 'Cursor token for cursor-based endpoints' })
@IsOptional()
@IsString()
cursor?: string;
@ApiPropertyOptional({
enum: SortOrder,
default: SortOrder.DESC,
description: 'Used by offset-based list endpoints. Cursor feeds may ignore it.',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@IsEnum(SortOrder)
sortOrder?: SortOrder = SortOrder.DESC;
}

عرض الملف

@@ -0,0 +1,5 @@
export enum ModerationStatus {
ACTIVE = 'active',
HIDDEN = 'hidden',
FLAGGED = 'flagged',
}

عرض الملف

@@ -3,4 +3,7 @@ export enum NotificationType {
COMMENT = 'comment',
FOLLOW = 'follow',
MESSAGE = 'message',
SAVE = 'save',
SHARE = 'share',
MENTION = 'mention',
}

عرض الملف

@@ -1,5 +1,6 @@
export enum PostType {
TEXT = 'text',
IMAGE = 'image',
VIDEO = 'video',
AUDIO = 'audio',
}

عرض الملف

@@ -0,0 +1,5 @@
export enum RepairRequestStatus {
PENDING = 'pending',
ACCEPTED = 'accepted',
COMPLETED = 'completed',
}

عرض الملف

@@ -0,0 +1,4 @@
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}

عرض الملف

@@ -0,0 +1,33 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
import { SUPERADMIN_PERMISSIONS_KEY } from '../decorators/superadmin-permissions.decorator';
@Injectable()
export class SuperAdminPermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
SUPERADMIN_PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions?.length) {
return true;
}
const request = context.switchToHttp().getRequest<{ user?: JwtPayload }>();
const payload = request.user;
const grantedPermissions = new Set(payload?.permissions ?? []);
const hasAllPermissions = requiredPermissions.every((permission) =>
grantedPermissions.has(permission),
);
if (!hasAllPermissions) {
throw new ForbiddenException('Missing superadmin permission');
}
return true;
}
}

عرض الملف

@@ -6,20 +6,17 @@ import {
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AppCacheService } from '../../infrastructure/cache/app-cache.service';
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,
private readonly cacheService: AppCacheService,
) {}
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const meta = this.reflector.getAllAndOverride<ThrottleMeta>(THROTTLE_META_KEY, [
context.getHandler(),
context.getClass(),
@@ -28,23 +25,25 @@ export class ThrottleGuard implements CanActivate {
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);
const req = context.switchToHttp().getRequest<
Request & {
ip?: string;
originalUrl?: string;
baseUrl?: string;
route?: { path?: string };
user?: { sub?: string };
}
>();
const actorKey = req.user?.sub ?? req.ip ?? 'unknown';
const routePath = `${req.baseUrl ?? ''}${req.route?.path ?? req.originalUrl ?? 'unknown-route'}`;
const windowSeconds = Math.max(1, Math.ceil(meta.windowMs / 1000));
const bucketKey = `rate-limit:${routePath}:${actorKey}`;
const currentCount = await this.cacheService.incr(bucketKey, windowSeconds);
if (!existing || now > existing.resetAt) {
this.buckets.set(key, { count: 1, resetAt: now + meta.windowMs });
return true;
}
if (existing.count >= meta.limit) {
if (currentCount > meta.limit) {
throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS);
}
existing.count += 1;
return true;
}
}

عرض الملف

@@ -4,4 +4,5 @@ export interface JwtPayload {
role?: string;
tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh';
email?: string;
permissions?: string[];
}

عرض الملف

@@ -0,0 +1,21 @@
import { toNumberArray, toStringArray } from './array-transform.util';
describe('array transform utils', () => {
it('wraps a single string as an array', () => {
expect(toStringArray({ value: '69e8d1f7d1f72ba6416d864b' } as any)).toEqual([
'69e8d1f7d1f72ba6416d864b',
]);
});
it('parses a JSON string array', () => {
expect(toStringArray({ value: '["a","b"]' } as any)).toEqual(['a', 'b']);
});
it('keeps array input as an array', () => {
expect(toStringArray({ value: ['a', 'b'] } as any)).toEqual(['a', 'b']);
});
it('parses numeric arrays from JSON strings', () => {
expect(toNumberArray({ value: '[1,2,3]' } as any)).toEqual([1, 2, 3]);
});
});

عرض الملف

@@ -0,0 +1,69 @@
import { TransformFnParams } from 'class-transformer';
const parseArrayInput = (value: unknown): unknown[] | unknown | undefined => {
if (value === undefined || value === null) {
return undefined;
}
if (Array.isArray(value)) {
return value.flatMap((item) => {
const parsed = parseArrayInput(item);
if (typeof parsed === 'undefined') {
return [];
}
return Array.isArray(parsed) ? parsed : [parsed];
});
}
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
return parseArrayInput(JSON.parse(trimmed));
} catch {
return [trimmed];
}
}
if (trimmed.includes(',')) {
return trimmed
.split(',')
.map((part) => part.trim())
.filter(Boolean);
}
return [trimmed];
};
export const toStringArray = ({ value }: TransformFnParams): string[] | unknown | undefined => {
const parsed = parseArrayInput(value);
if (typeof parsed === 'undefined') {
return undefined;
}
if (!Array.isArray(parsed)) {
return parsed;
}
return parsed.map((item) => String(item).trim()).filter(Boolean);
};
export const toNumberArray = ({ value }: TransformFnParams): number[] | unknown | undefined => {
const parsed = parseArrayInput(value);
if (typeof parsed === 'undefined') {
return undefined;
}
if (!Array.isArray(parsed)) {
return parsed;
}
return parsed.map((item) => Number(item));
};

عرض الملف

@@ -1,7 +1,24 @@
import * as bcrypt from 'bcrypt';
import { createHmac, timingSafeEqual } from 'crypto';
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);
export const hashHighEntropyValue = (value: string, secret: string): string =>
`sha256:${createHmac('sha256', secret).update(value).digest('hex')}`;
export const compareStoredHighEntropyValue = async (
value: string,
storedValue: string,
secret: string,
): Promise<boolean> => {
if (storedValue.startsWith('sha256:')) {
const nextValue = hashHighEntropyValue(value, secret);
return timingSafeEqual(Buffer.from(nextValue), Buffer.from(storedValue));
}
return compareHash(value, storedValue);
};

عرض الملف

@@ -0,0 +1,37 @@
import { buildPaginatedResponse } from './pagination.util';
describe('pagination util', () => {
it('builds offset pagination metadata', () => {
const result = buildPaginatedResponse(['a', 'b'], {
page: 2,
limit: 2,
total: 5,
offset: 2,
});
expect(result.count).toBe(2);
expect(result.totalPages).toBe(3);
expect(result.pagination.hasNextPage).toBe(true);
expect(result.pagination.hasPreviousPage).toBe(true);
expect(result.pagination.nextPage).toBe(3);
expect(result.pagination.previousPage).toBe(1);
expect(result.pagination.mode).toBe('offset');
});
it('builds cursor pagination metadata', () => {
const result = buildPaginatedResponse(['a'], {
page: 1,
limit: 2,
total: 3,
offset: 0,
currentCursor: 'cursor-a',
nextCursor: 'cursor-b',
mode: 'cursor',
});
expect(result.nextCursor).toBe('cursor-b');
expect(result.pagination.currentCursor).toBe('cursor-a');
expect(result.pagination.nextCursor).toBe('cursor-b');
expect(result.pagination.mode).toBe('cursor');
});
});

عرض الملف

@@ -0,0 +1,70 @@
export type PaginatedResponseOptions = {
page: number;
limit: number;
total: number;
offset: number;
currentCursor?: string | null;
nextCursor?: string | null;
mode?: 'offset' | 'cursor';
};
export type PaginatedResponse<T> = {
items: T[];
count: number;
page: number;
limit: number;
total: number;
totalPages: number;
nextCursor: string | null;
pagination: {
mode: 'offset' | 'cursor';
page: number;
limit: number;
count: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
nextPage: number | null;
previousPage: number | null;
currentCursor: string | null;
nextCursor: string | null;
};
};
export const buildPaginatedResponse = <T>(
items: T[],
options: PaginatedResponseOptions,
): PaginatedResponse<T> => {
const count = items.length;
const totalPages = Math.ceil(options.total / options.limit) || 1;
const hasNextPage = options.offset + count < options.total;
const hasPreviousPage = options.offset > 0;
const mode =
options.mode ??
(options.currentCursor !== undefined || options.nextCursor !== undefined ? 'cursor' : 'offset');
return {
items,
count,
page: options.page,
limit: options.limit,
total: options.total,
totalPages,
nextCursor: options.nextCursor ?? null,
pagination: {
mode,
page: options.page,
limit: options.limit,
count,
total: options.total,
totalPages,
hasNextPage,
hasPreviousPage,
nextPage: hasNextPage ? options.page + 1 : null,
previousPage: hasPreviousPage ? Math.max(1, options.page - 1) : null,
currentCursor: options.currentCursor ?? null,
nextCursor: options.nextCursor ?? null,
},
};
};

عرض الملف

@@ -0,0 +1,29 @@
import { resolveManagedFileUrl, resolveManagedFileUrls } from './public-url.util';
describe('public url util', () => {
const originalPublicBaseUrl = process.env.PUBLIC_BASE_URL;
const originalStorageBasePath = process.env.STORAGE_BASE_PATH;
beforeEach(() => {
process.env.PUBLIC_BASE_URL = 'http://192.168.1.12:4000';
process.env.STORAGE_BASE_PATH = 'uploads';
});
afterEach(() => {
process.env.PUBLIC_BASE_URL = originalPublicBaseUrl;
process.env.STORAGE_BASE_PATH = originalStorageBasePath;
});
it('resolves a local managed file url', () => {
expect(resolveManagedFileUrl('/uploads/posts/images/file.png')).toBe(
'http://192.168.1.12:4000/uploads/posts/images/file.png',
);
});
it('resolves arrays of local managed file urls', () => {
expect(resolveManagedFileUrls(['/uploads/a.png', '/uploads/b.png'])).toEqual([
'http://192.168.1.12:4000/uploads/a.png',
'http://192.168.1.12:4000/uploads/b.png',
]);
});
});

عرض الملف

@@ -0,0 +1,27 @@
const getUploadsBasePath = (): string =>
`/${(process.env.STORAGE_BASE_PATH ?? 'uploads').replace(/^\/+|\/+$/g, '')}/`;
export const resolveManagedFileUrl = (fileUrl: unknown): unknown => {
if (typeof fileUrl !== 'string' || !fileUrl.trim()) {
return fileUrl;
}
if (!fileUrl.startsWith(getUploadsBasePath())) {
return fileUrl;
}
const baseUrl = (process.env.PUBLIC_BASE_URL ?? '').replace(/\/$/, '');
if (!baseUrl) {
return fileUrl;
}
return `${baseUrl}${fileUrl}`;
};
export const resolveManagedFileUrls = (fileUrls: unknown): unknown => {
if (!Array.isArray(fileUrls)) {
return fileUrls;
}
return fileUrls.map((fileUrl) => resolveManagedFileUrl(fileUrl));
};

عرض الملف

@@ -0,0 +1,16 @@
import { toBoolean } from './query-transform.util';
describe('query transform util', () => {
it('converts "true" and "false" string values correctly', () => {
expect(toBoolean({ value: 'true' })).toBe(true);
expect(toBoolean({ value: 'TRUE' })).toBe(true);
expect(toBoolean({ value: 'false' })).toBe(false);
expect(toBoolean({ value: 'FALSE' })).toBe(false);
});
it('leaves unrelated values unchanged', () => {
expect(toBoolean({ value: '0' })).toBe('0');
expect(toBoolean({ value: 'hello' })).toBe('hello');
expect(toBoolean({ value: 1 })).toBe(1);
});
});

عرض الملف

@@ -0,0 +1,17 @@
export const toBoolean = ({ value }: { value: unknown }): unknown => {
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true') {
return true;
}
if (normalized === 'false') {
return false;
}
}
if (value === true || value === false) {
return value;
}
return value;
};

عرض الملف

@@ -0,0 +1,13 @@
import { SortOrder } from '../enums/sort-order.enum';
import { resolveMongoSortDirection } from './sort.util';
describe('sort util', () => {
it('returns ascending for asc', () => {
expect(resolveMongoSortDirection(SortOrder.ASC)).toBe(1);
});
it('returns descending by default', () => {
expect(resolveMongoSortDirection(undefined)).toBe(-1);
expect(resolveMongoSortDirection(SortOrder.DESC)).toBe(-1);
});
});

عرض الملف

@@ -0,0 +1,7 @@
import { SortOrder } from '../enums/sort-order.enum';
export type MongoSortDirection = 1 | -1;
export const resolveMongoSortDirection = (
sortOrder?: SortOrder | null,
): MongoSortDirection => (sortOrder === SortOrder.ASC ? 1 : -1);

عرض الملف

@@ -0,0 +1,101 @@
const DEFAULT_SAMPLES = 48;
const scaleToRange = (values: number[]): number[] => {
if (!values.length) {
return [];
}
const max = Math.max(...values);
if (max <= 0) {
return values.map(() => 0);
}
return values.map((value) => Math.max(0, Math.min(100, Math.round((value / max) * 100))));
};
export const normalizeWaveformPeaks = (
input: number[] | undefined,
maxSamples = DEFAULT_SAMPLES,
): number[] => {
if (!input?.length) {
return [];
}
const cleaned = input
.map((value) => (Number.isFinite(value) ? Math.abs(value) : 0))
.filter((value) => value > 0);
if (!cleaned.length) {
return [];
}
if (cleaned.length <= maxSamples) {
return scaleToRange(cleaned);
}
const windowSize = cleaned.length / maxSamples;
const compressed: number[] = [];
for (let i = 0; i < maxSamples; i += 1) {
const start = Math.floor(i * windowSize);
const end = Math.min(cleaned.length, Math.floor((i + 1) * windowSize));
const slice = cleaned.slice(start, Math.max(start + 1, end));
const peak = Math.max(...slice);
compressed.push(peak);
}
return scaleToRange(compressed);
};
export const generateWaveformPeaksFromBuffer = (
buffer: Buffer,
samples = DEFAULT_SAMPLES,
): number[] => {
if (!buffer.length) {
return [];
}
const chunkSize = Math.max(1, Math.ceil(buffer.length / samples));
const peaks: number[] = [];
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
const chunk = buffer.subarray(offset, Math.min(buffer.length, offset + chunkSize));
let total = 0;
let localPeak = 0;
for (const byte of chunk) {
const centered = Math.abs(byte - 128);
total += centered;
localPeak = Math.max(localPeak, centered);
}
const average = chunk.length ? total / chunk.length : 0;
peaks.push(Math.max(average, localPeak * 0.65));
}
return normalizeWaveformPeaks(peaks, samples);
};
export const generateWaveformPeaksFromSeed = (
seed: string,
samples = DEFAULT_SAMPLES,
): number[] => {
const source = seed.trim() || 'audio';
let hash = 2166136261;
for (let i = 0; i < source.length; i += 1) {
hash ^= source.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
const peaks: number[] = [];
let state = hash >>> 0;
for (let i = 0; i < samples; i += 1) {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
const base = 18 + (state % 65);
const accent = i % 6 === 0 ? 18 : i % 3 === 0 ? 10 : 0;
peaks.push(base + accent);
}
return normalizeWaveformPeaks(peaks, samples);
};