feat: expand backend admin marketplace and scaling
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
هذا الالتزام موجود في:
21
src/common/utils/array-transform.util.spec.ts
Normal file
21
src/common/utils/array-transform.util.spec.ts
Normal file
@@ -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]);
|
||||
});
|
||||
});
|
||||
69
src/common/utils/array-transform.util.ts
Normal file
69
src/common/utils/array-transform.util.ts
Normal file
@@ -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);
|
||||
};
|
||||
|
||||
37
src/common/utils/pagination.util.spec.ts
Normal file
37
src/common/utils/pagination.util.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
70
src/common/utils/pagination.util.ts
Normal file
70
src/common/utils/pagination.util.ts
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
29
src/common/utils/public-url.util.spec.ts
Normal file
29
src/common/utils/public-url.util.spec.ts
Normal file
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
27
src/common/utils/public-url.util.ts
Normal file
27
src/common/utils/public-url.util.ts
Normal file
@@ -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));
|
||||
};
|
||||
16
src/common/utils/query-transform.util.spec.ts
Normal file
16
src/common/utils/query-transform.util.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
17
src/common/utils/query-transform.util.ts
Normal file
17
src/common/utils/query-transform.util.ts
Normal file
@@ -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;
|
||||
};
|
||||
13
src/common/utils/sort.util.spec.ts
Normal file
13
src/common/utils/sort.util.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
7
src/common/utils/sort.util.ts
Normal file
7
src/common/utils/sort.util.ts
Normal file
@@ -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);
|
||||
101
src/common/utils/waveform.util.ts
Normal file
101
src/common/utils/waveform.util.ts
Normal file
@@ -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);
|
||||
};
|
||||
المرجع في مشكلة جديدة
حظر مستخدم