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,102 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
type MemoryEntry = {
value: string;
expiresAt: number | null;
};
@Injectable()
export class AppCacheService {
private readonly memory = new Map<string, MemoryEntry>();
constructor(private readonly redisService: RedisService) {}
async get<T>(key: string): Promise<T | null> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
if (redis) {
const value = await redis.get(fullKey);
if (!value) {
return null;
}
return JSON.parse(value) as T;
}
const memoryEntry = this.memory.get(fullKey);
if (!memoryEntry) {
return null;
}
if (memoryEntry.expiresAt && memoryEntry.expiresAt <= Date.now()) {
this.memory.delete(fullKey);
return null;
}
return JSON.parse(memoryEntry.value) as T;
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
const serialized = JSON.stringify(value);
if (redis) {
if (ttlSeconds && ttlSeconds > 0) {
await redis.set(fullKey, serialized, 'EX', ttlSeconds);
return;
}
await redis.set(fullKey, serialized);
return;
}
const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
this.memory.set(fullKey, { value: serialized, expiresAt });
}
async del(key: string): Promise<void> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
if (redis) {
await redis.del(fullKey);
return;
}
this.memory.delete(fullKey);
}
async remember<T>(key: string, ttlSeconds: number, factory: () => Promise<T>): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) {
return cached;
}
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
}
async incr(key: string, ttlSeconds?: number): Promise<number> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
if (redis) {
const nextValue = await redis.incr(fullKey);
if (ttlSeconds && ttlSeconds > 0 && nextValue === 1) {
await redis.expire(fullKey, ttlSeconds);
}
return nextValue;
}
const existing = await this.get<number>(key);
const nextValue = (existing ?? 0) + 1;
await this.set(key, nextValue, ttlSeconds);
return nextValue;
}
private buildKey(key: string): string {
return `${this.redisService.getKeyPrefix()}:${key}`;
}
}

عرض الملف

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { AppCacheService } from './app-cache.service';
import { FeedVersionService } from './feed-version.service';
@Global()
@Module({
providers: [AppCacheService, FeedVersionService],
exports: [AppCacheService, FeedVersionService],
})
export class CacheModule {}

عرض الملف

@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { AppCacheService } from './app-cache.service';
@Injectable()
export class FeedVersionService {
private static readonly GLOBAL_VERSION_KEY = 'feed:global:version';
constructor(private readonly cacheService: AppCacheService) {}
async getGlobalVersion(): Promise<number> {
const current = await this.cacheService.get<number>(FeedVersionService.GLOBAL_VERSION_KEY);
if (typeof current === 'number' && current > 0) {
return current;
}
await this.cacheService.set(FeedVersionService.GLOBAL_VERSION_KEY, 1);
return 1;
}
async bumpGlobalVersion(): Promise<number> {
return this.cacheService.incr(FeedVersionService.GLOBAL_VERSION_KEY);
}
}

عرض الملف

@@ -0,0 +1,91 @@
import { Injectable, LoggerService } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
type AppLogLevel = 'error' | 'warn' | 'log' | 'debug' | 'verbose';
@Injectable()
export class AppLoggerService implements LoggerService {
private readonly levelPriority: Record<AppLogLevel, number> = {
error: 0,
warn: 1,
log: 2,
debug: 3,
verbose: 4,
};
constructor(private readonly configService: ConfigService) {}
log(message: any, context?: string): void {
this.write('log', message, undefined, context);
}
error(message: any, trace?: string, context?: string): void {
this.write('error', message, trace, context);
}
warn(message: any, context?: string): void {
this.write('warn', message, undefined, context);
}
debug(message: any, context?: string): void {
this.write('debug', message, undefined, context);
}
verbose(message: any, context?: string): void {
this.write('verbose', message, undefined, context);
}
logHttp(payload: Record<string, unknown>): void {
this.write('log', 'http_request', undefined, 'HttpLogger', payload);
}
private write(
level: AppLogLevel,
message: any,
trace?: string,
context?: string,
extra: Record<string, unknown> = {},
): void {
if (!this.shouldLog(level)) {
return;
}
const entry: Record<string, unknown> = {
level,
timestamp: new Date().toISOString(),
context: context ?? 'Application',
...extra,
};
if (typeof message === 'string') {
entry.message = message;
} else if (message instanceof Error) {
entry.message = message.message;
entry.errorName = message.name;
entry.stack = message.stack;
} else {
entry.message = 'structured_log';
entry.payload = message;
}
if (trace) {
entry.trace = trace;
}
const serialized = `${JSON.stringify(entry)}\n`;
if (level === 'error') {
process.stderr.write(serialized);
return;
}
process.stdout.write(serialized);
}
private shouldLog(level: AppLogLevel): boolean {
const configuredLevel =
(this.configService.get<string>('logging.level', { infer: true }) as AppLogLevel | undefined) ??
'log';
return this.levelPriority[level] <= this.levelPriority[configuredLevel];
}
}

عرض الملف

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AppLoggerService } from './app-logger.service';
@Global()
@Module({
providers: [AppLoggerService],
exports: [AppLoggerService],
})
export class LoggingModule {}

عرض الملف

@@ -0,0 +1,145 @@
import {
Injectable,
OnApplicationBootstrap,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JobsOptions, Queue, Worker } from 'bullmq';
import { AppLoggerService } from '../logging/app-logger.service';
import { RedisService } from '../redis/redis.service';
type JobProcessor = (payload: Record<string, unknown>) => Promise<void>;
@Injectable()
export class AppQueueService
implements OnModuleInit, OnApplicationBootstrap, OnModuleDestroy
{
private readonly processors = new Map<string, JobProcessor>();
private queue: Queue | null = null;
private worker: Worker | null = null;
constructor(
private readonly configService: ConfigService,
private readonly redisService: RedisService,
private readonly logger: AppLoggerService,
) {}
onModuleInit(): void {
// Intentionally empty. Processors are usually registered by other providers before bootstrap.
}
onApplicationBootstrap(): void {
if (!this.isQueueEnabled() || !this.redisService.isEnabled()) {
return;
}
const queueName = this.getQueueName();
const queueConnection = this.redisService.createQueueClient();
const workerConnection = this.redisService.createQueueClient();
if (!queueConnection || !workerConnection) {
return;
}
this.queue = new Queue(queueName, {
connection: queueConnection,
defaultJobOptions: this.getDefaultJobOptions(),
});
this.worker = new Worker(
queueName,
async (job) => {
const processor = this.processors.get(job.name);
if (!processor) {
throw new Error(`No processor registered for job "${job.name}"`);
}
await processor(job.data as Record<string, unknown>);
},
{
connection: workerConnection,
concurrency:
this.configService.get<number>('queue.workerConcurrency', { infer: true }) ?? 5,
},
);
this.worker.on('failed', (job, error) => {
this.logger.error(
{
queue: queueName,
jobName: job?.name,
jobId: job?.id,
error: error.message,
},
undefined,
AppQueueService.name,
);
});
}
registerProcessor(jobName: string, processor: JobProcessor): void {
this.processors.set(jobName, processor);
}
async enqueue(
jobName: string,
payload: Record<string, unknown>,
options: JobsOptions = {},
): Promise<void> {
if (this.queue) {
await this.queue.add(jobName, payload, {
...this.getDefaultJobOptions(),
...options,
});
return;
}
const processor = this.processors.get(jobName);
if (!processor) {
return;
}
queueMicrotask(() => {
void processor(payload).catch((error: Error) => {
this.logger.error(
{
jobName,
payload,
error: error.message,
},
error.stack,
AppQueueService.name,
);
});
});
}
async onModuleDestroy(): Promise<void> {
await this.worker?.close();
await this.queue?.close();
this.worker = null;
this.queue = null;
}
private isQueueEnabled(): boolean {
return this.configService.get<boolean>('queue.enabled', { infer: true }) ?? false;
}
private getQueueName(): string {
return this.configService.get<string>('queue.name', { infer: true }) ?? 'app-jobs';
}
private getDefaultJobOptions(): JobsOptions {
return {
attempts:
this.configService.get<number>('queue.defaultJobAttempts', { infer: true }) ?? 3,
backoff: {
type: 'exponential',
delay:
this.configService.get<number>('queue.defaultJobBackoffMs', { infer: true }) ?? 1000,
},
removeOnComplete:
this.configService.get<boolean>('queue.removeOnComplete', { infer: true }) ?? true,
};
}
}

عرض الملف

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AppQueueService } from './app-queue.service';
@Global()
@Module({
providers: [AppQueueService],
exports: [AppQueueService],
})
export class QueueModule {}

عرض الملف

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

عرض الملف

@@ -0,0 +1,79 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis, { RedisOptions } from 'ioredis';
@Injectable()
export class RedisService implements OnModuleDestroy {
private client: Redis | null = null;
constructor(private readonly configService: ConfigService) {}
isEnabled(): boolean {
return this.configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
}
getKeyPrefix(): string {
return this.configService.get<string>('redis.keyPrefix', { infer: true }) ?? 'oudelaa';
}
getClient(): Redis | null {
if (!this.isEnabled()) {
return null;
}
if (!this.client) {
this.client = this.createClient({ maxRetriesPerRequest: null });
}
return this.client;
}
createPubSubClients(): { pubClient: Redis; subClient: Redis } | null {
if (!this.isEnabled()) {
return null;
}
const pubClient = this.createClient({ maxRetriesPerRequest: null });
const subClient = pubClient.duplicate();
return { pubClient, subClient };
}
createQueueClient(): Redis | null {
if (!this.isEnabled()) {
return null;
}
return this.createClient({ maxRetriesPerRequest: null });
}
onModuleDestroy(): void {
if (this.client) {
void this.client.quit().catch(() => this.client?.disconnect());
this.client = null;
}
}
private createClient(overrides: Partial<RedisOptions> = {}): Redis {
const url = this.configService.get<string>('redis.url', { infer: true }) ?? '';
const baseOptions: RedisOptions = {
host: this.configService.get<string>('redis.host', { infer: true }) ?? '127.0.0.1',
port: this.configService.get<number>('redis.port', { infer: true }) ?? 6379,
username: this.configService.get<string>('redis.username', { infer: true }) || undefined,
password: this.configService.get<string>('redis.password', { infer: true }) || undefined,
db: this.configService.get<number>('redis.db', { infer: true }) ?? 0,
lazyConnect: false,
enableReadyCheck: true,
...overrides,
};
if (url) {
return new Redis(url, {
...overrides,
lazyConnect: false,
enableReadyCheck: true,
});
}
return new Redis(baseOptions);
}
}

عرض الملف

@@ -0,0 +1,45 @@
import { INestApplicationContext } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { ServerOptions } from 'socket.io';
import Redis from 'ioredis';
import { RedisService } from '../redis/redis.service';
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter> | null = null;
private pubClient: Redis | null = null;
private subClient: Redis | null = null;
constructor(
app: INestApplicationContext,
private readonly redisService: RedisService,
) {
super(app);
}
async connectToRedis(): Promise<void> {
const clients = this.redisService.createPubSubClients();
if (!clients) {
return;
}
this.pubClient = clients.pubClient;
this.subClient = clients.subClient;
this.adapterConstructor = createAdapter(this.pubClient as any, this.subClient as any);
}
createIOServer(port: number, options?: ServerOptions) {
const server = super.createIOServer(port, options);
if (this.adapterConstructor) {
server.adapter(this.adapterConstructor);
}
return server;
}
async close(): Promise<void> {
await this.pubClient?.quit().catch(() => this.pubClient?.disconnect());
await this.subClient?.quit().catch(() => this.subClient?.disconnect());
this.pubClient = null;
this.subClient = null;
}
}

عرض الملف

@@ -0,0 +1,210 @@
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { randomUUID } from 'crypto';
import { mkdir, unlink, writeFile } from 'fs/promises';
import { join, posix } from 'path';
@Injectable()
export class ManagedStorageService implements OnModuleDestroy {
private s3Client: S3Client | null = null;
constructor(private readonly configService: ConfigService) {}
async saveFile(params: {
folderSegments: string[];
extension: string;
buffer: Buffer;
contentType?: string;
fileNamePrefix?: string;
}): Promise<string> {
const fileName = `${params.fileNamePrefix ?? 'file'}-${randomUUID()}${params.extension}`;
const provider = this.getProvider();
const basePath = this.getBasePath();
const normalizedSegments = params.folderSegments.map((segment) =>
segment.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
);
const objectKey = posix.join(basePath, ...normalizedSegments, fileName);
if (provider === 's3') {
const client = this.getS3Client();
const upload = new Upload({
client,
params: {
Bucket: this.getS3Bucket(),
Key: objectKey,
Body: params.buffer,
ContentType: params.contentType || undefined,
},
});
await upload.done();
return this.resolvePublicUrl(objectKey);
}
const uploadDir = join(process.cwd(), ...objectKey.split('/').slice(0, -1));
await mkdir(uploadDir, { recursive: true });
await writeFile(join(process.cwd(), ...objectKey.split('/')), params.buffer);
return `/${objectKey}`;
}
async deleteFile(fileUrl?: string): Promise<void> {
if (!fileUrl) {
return;
}
if (this.getProvider() === 's3') {
const objectKey = this.resolveS3ObjectKey(fileUrl);
if (!objectKey) {
return;
}
const client = this.getS3Client();
await client.send(
new DeleteObjectCommand({
Bucket: this.getS3Bucket(),
Key: objectKey,
}),
);
return;
}
const relativePath = this.resolveLocalRelativePath(fileUrl);
if (!relativePath || relativePath.includes('..')) {
return;
}
try {
await unlink(join(process.cwd(), relativePath.replace(/\//g, '\\')));
} catch {
// Ignore cleanup failures for already-missing files.
}
}
onModuleDestroy(): void {
this.s3Client = null;
}
private getProvider(): 'local' | 's3' {
return (this.configService.get<string>('storage.provider', { infer: true }) as
| 'local'
| 's3'
| undefined) ?? 'local';
}
private getBasePath(): string {
return (this.configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads')
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '');
}
private getS3Bucket(): string {
const bucket = this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '';
if (!bucket) {
throw new BadRequestException('S3 bucket is not configured');
}
return bucket;
}
private getS3Client(): S3Client {
if (this.s3Client) {
return this.s3Client;
}
const region = this.configService.get<string>('storage.s3.region', { infer: true }) ?? 'auto';
const endpoint = this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '';
const accessKeyId =
this.configService.get<string>('storage.s3.accessKeyId', { infer: true }) ?? '';
const secretAccessKey =
this.configService.get<string>('storage.s3.secretAccessKey', { infer: true }) ?? '';
const forcePathStyle =
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
if (!endpoint || !accessKeyId || !secretAccessKey) {
throw new BadRequestException('S3 storage settings are not fully configured');
}
this.s3Client = new S3Client({
region,
endpoint,
forcePathStyle,
credentials: {
accessKeyId,
secretAccessKey,
},
});
return this.s3Client;
}
private resolvePublicUrl(objectKey: string): string {
const publicBaseUrl =
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
/\/$/,
'',
);
if (publicBaseUrl) {
return `${publicBaseUrl}/${objectKey}`;
}
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
/\/$/,
'',
);
const bucket = this.getS3Bucket();
const forcePathStyle =
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
if (!endpoint) {
throw new BadRequestException('storage.publicBaseUrl or storage.s3.endpoint is required');
}
return forcePathStyle ? `${endpoint}/${bucket}/${objectKey}` : `${endpoint}/${objectKey}`;
}
private resolveLocalRelativePath(fileUrl: string): string | null {
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
if (!normalizedUrl.startsWith('/')) {
return null;
}
const expectedPrefix = `/${this.getBasePath()}/`;
if (!normalizedUrl.startsWith(expectedPrefix) && normalizedUrl !== `/${this.getBasePath()}`) {
return null;
}
return normalizedUrl.replace(/^\/+/, '');
}
private resolveS3ObjectKey(fileUrl: string): string | null {
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
const publicBaseUrl =
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
/\/$/,
'',
);
if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
return normalizedUrl.slice(publicBaseUrl.length + 1);
}
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
/\/$/,
'',
);
const bucket = this.getS3Bucket();
const forcePathStyle =
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
if (endpoint && normalizedUrl.startsWith(`${endpoint}/`)) {
const pathPart = normalizedUrl.slice(endpoint.length + 1);
if (forcePathStyle) {
const expectedPrefix = `${bucket}/`;
return pathPart.startsWith(expectedPrefix) ? pathPart.slice(expectedPrefix.length) : null;
}
return pathPart;
}
return null;
}
}

عرض الملف

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { ManagedStorageService } from './managed-storage.service';
@Global()
@Module({
providers: [ManagedStorageService],
exports: [ManagedStorageService],
})
export class StorageModule {}