feat: expand backend admin marketplace and scaling
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
هذا الالتزام موجود في:
@@ -6,6 +6,11 @@ import { AppService } from './app.service';
|
||||
import configuration from './config/configuration';
|
||||
import { validationSchema } from './config/validation.schema';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { CacheModule } from './infrastructure/cache/cache.module';
|
||||
import { LoggingModule } from './infrastructure/logging/logging.module';
|
||||
import { QueueModule } from './infrastructure/queue/queue.module';
|
||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||
import { StorageModule } from './infrastructure/storage/storage.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { AuditModule } from './modules/audit/audit.module';
|
||||
import { ChatModule } from './modules/chat/chat.module';
|
||||
@@ -19,6 +24,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
||||
import { OutboxModule } from './modules/outbox/outbox.module';
|
||||
import { PostsModule } from './modules/posts/posts.module';
|
||||
import { SavesModule } from './modules/saves/saves.module';
|
||||
import { SuperAdminModule } from './modules/superadmin/superadmin.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||
|
||||
@@ -30,6 +36,11 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||
load: [configuration],
|
||||
validationSchema,
|
||||
}),
|
||||
LoggingModule,
|
||||
RedisModule,
|
||||
CacheModule,
|
||||
StorageModule,
|
||||
QueueModule,
|
||||
DatabaseModule,
|
||||
AuditModule,
|
||||
UsersModule,
|
||||
@@ -45,6 +56,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||
MediaModule,
|
||||
MarketplaceModule,
|
||||
SavesModule,
|
||||
SuperAdminModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
5
src/common/enums/moderation-status.enum.ts
Normal file
5
src/common/enums/moderation-status.enum.ts
Normal file
@@ -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',
|
||||
}
|
||||
|
||||
5
src/common/enums/repair-request-status.enum.ts
Normal file
5
src/common/enums/repair-request-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum RepairRequestStatus {
|
||||
PENDING = 'pending',
|
||||
ACCEPTED = 'accepted',
|
||||
COMPLETED = 'completed',
|
||||
}
|
||||
4
src/common/enums/sort-order.enum.ts
Normal file
4
src/common/enums/sort-order.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum SortOrder {
|
||||
ASC = 'asc',
|
||||
DESC = 'desc',
|
||||
}
|
||||
33
src/common/guards/superadmin-permissions.guard.ts
Normal file
33
src/common/guards/superadmin-permissions.guard.ts
Normal file
@@ -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[];
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -47,8 +47,61 @@ export default () => ({
|
||||
fromName: process.env.EMAIL_FROM_NAME ?? 'Oudelaa',
|
||||
fromEmail: process.env.EMAIL_FROM_EMAIL ?? process.env.EMAIL_SMTP_USER ?? '',
|
||||
},
|
||||
aiMusic: {
|
||||
enabled: (process.env.AI_MUSIC_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||
apiKey: process.env.AI_MUSIC_API_KEY ?? '',
|
||||
projectId: process.env.AI_MUSIC_PROJECT_ID ?? '',
|
||||
location: process.env.AI_MUSIC_LOCATION ?? 'us-central1',
|
||||
model: process.env.AI_MUSIC_MODEL ?? 'lyria-002',
|
||||
},
|
||||
security: {
|
||||
bcryptSaltRounds: Number(process.env.BCRYPT_SALT_ROUNDS ?? 12),
|
||||
refreshTokenHashSecret:
|
||||
process.env.REFRESH_TOKEN_HASH_SECRET ?? process.env.JWT_REFRESH_SECRET ?? '',
|
||||
},
|
||||
redis: {
|
||||
enabled: (process.env.REDIS_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||
url: process.env.REDIS_URL ?? '',
|
||||
host: process.env.REDIS_HOST ?? '127.0.0.1',
|
||||
port: Number(process.env.REDIS_PORT ?? 6379),
|
||||
username: process.env.REDIS_USERNAME ?? '',
|
||||
password: process.env.REDIS_PASSWORD ?? '',
|
||||
db: Number(process.env.REDIS_DB ?? 0),
|
||||
keyPrefix: process.env.REDIS_KEY_PREFIX ?? 'oudelaa',
|
||||
socketAdapterEnabled:
|
||||
(process.env.REDIS_SOCKET_ADAPTER_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||
},
|
||||
queue: {
|
||||
enabled: (process.env.QUEUE_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||
name: process.env.QUEUE_NAME ?? 'app-jobs',
|
||||
defaultJobAttempts: Number(process.env.QUEUE_DEFAULT_ATTEMPTS ?? 3),
|
||||
defaultJobBackoffMs: Number(process.env.QUEUE_DEFAULT_BACKOFF_MS ?? 1000),
|
||||
removeOnComplete:
|
||||
(process.env.QUEUE_REMOVE_ON_COMPLETE ?? 'true').toLowerCase() === 'true',
|
||||
workerConcurrency: Number(process.env.QUEUE_WORKER_CONCURRENCY ?? 5),
|
||||
},
|
||||
storage: {
|
||||
provider: process.env.STORAGE_PROVIDER ?? 'local',
|
||||
basePath: process.env.STORAGE_BASE_PATH ?? 'uploads',
|
||||
publicBaseUrl: process.env.STORAGE_PUBLIC_BASE_URL ?? '',
|
||||
s3: {
|
||||
bucket: process.env.S3_BUCKET ?? '',
|
||||
region: process.env.S3_REGION ?? 'auto',
|
||||
endpoint: process.env.S3_ENDPOINT ?? '',
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '',
|
||||
forcePathStyle:
|
||||
(process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL ?? 'log',
|
||||
requestEnabled: (process.env.REQUEST_LOGGING_ENABLED ?? 'true').toLowerCase() === 'true',
|
||||
},
|
||||
feedCache: {
|
||||
enabled: (process.env.FEED_CACHE_ENABLED ?? 'true').toLowerCase() === 'true',
|
||||
userFeedTtlSeconds: Number(process.env.FEED_CACHE_USER_TTL_SECONDS ?? 15),
|
||||
trendingTtlSeconds: Number(process.env.FEED_CACHE_TRENDING_TTL_SECONDS ?? 30),
|
||||
},
|
||||
passwordReset: {
|
||||
codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10),
|
||||
|
||||
@@ -30,7 +30,42 @@ export const validationSchema = Joi.object({
|
||||
EMAIL_SMTP_PASS: Joi.string().allow('').optional(),
|
||||
EMAIL_FROM_NAME: Joi.string().default('Oudelaa'),
|
||||
EMAIL_FROM_EMAIL: Joi.string().allow('').optional(),
|
||||
AI_MUSIC_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||
AI_MUSIC_API_KEY: Joi.string().allow('').optional(),
|
||||
AI_MUSIC_PROJECT_ID: Joi.string().allow('').optional(),
|
||||
AI_MUSIC_LOCATION: Joi.string().default('us-central1'),
|
||||
AI_MUSIC_MODEL: Joi.string().default('lyria-002'),
|
||||
BCRYPT_SALT_ROUNDS: Joi.number().min(8).max(15).default(12),
|
||||
REFRESH_TOKEN_HASH_SECRET: Joi.string().allow('').optional(),
|
||||
REDIS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||
REDIS_URL: Joi.string().allow('').optional(),
|
||||
REDIS_HOST: Joi.string().default('127.0.0.1'),
|
||||
REDIS_PORT: Joi.number().default(6379),
|
||||
REDIS_USERNAME: Joi.string().allow('').optional(),
|
||||
REDIS_PASSWORD: Joi.string().allow('').optional(),
|
||||
REDIS_DB: Joi.number().min(0).default(0),
|
||||
REDIS_KEY_PREFIX: Joi.string().default('oudelaa'),
|
||||
REDIS_SOCKET_ADAPTER_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||
QUEUE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||
QUEUE_NAME: Joi.string().default('app-jobs'),
|
||||
QUEUE_DEFAULT_ATTEMPTS: Joi.number().min(1).max(20).default(3),
|
||||
QUEUE_DEFAULT_BACKOFF_MS: Joi.number().min(100).max(600000).default(1000),
|
||||
QUEUE_REMOVE_ON_COMPLETE: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||
QUEUE_WORKER_CONCURRENCY: Joi.number().min(1).max(100).default(5),
|
||||
STORAGE_PROVIDER: Joi.string().valid('local', 's3').default('local'),
|
||||
STORAGE_BASE_PATH: Joi.string().default('uploads'),
|
||||
STORAGE_PUBLIC_BASE_URL: Joi.string().allow('').optional(),
|
||||
S3_BUCKET: Joi.string().allow('').optional(),
|
||||
S3_REGION: Joi.string().allow('').default('auto'),
|
||||
S3_ENDPOINT: Joi.string().allow('').optional(),
|
||||
S3_ACCESS_KEY_ID: Joi.string().allow('').optional(),
|
||||
S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(),
|
||||
S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||
LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'),
|
||||
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||
FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||
FEED_CACHE_USER_TTL_SECONDS: Joi.number().min(1).max(3600).default(15),
|
||||
FEED_CACHE_TRENDING_TTL_SECONDS: Joi.number().min(1).max(3600).default(30),
|
||||
PASSWORD_RESET_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
|
||||
PASSWORD_RESET_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
|
||||
PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(),
|
||||
|
||||
102
src/infrastructure/cache/app-cache.service.ts
مباع
Normal file
102
src/infrastructure/cache/app-cache.service.ts
مباع
Normal file
@@ -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}`;
|
||||
}
|
||||
}
|
||||
10
src/infrastructure/cache/cache.module.ts
مباع
Normal file
10
src/infrastructure/cache/cache.module.ts
مباع
Normal file
@@ -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 {}
|
||||
23
src/infrastructure/cache/feed-version.service.ts
مباع
Normal file
23
src/infrastructure/cache/feed-version.service.ts
مباع
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
91
src/infrastructure/logging/app-logger.service.ts
Normal file
91
src/infrastructure/logging/app-logger.service.ts
Normal file
@@ -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];
|
||||
}
|
||||
}
|
||||
9
src/infrastructure/logging/logging.module.ts
Normal file
9
src/infrastructure/logging/logging.module.ts
Normal file
@@ -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 {}
|
||||
145
src/infrastructure/queue/app-queue.service.ts
Normal file
145
src/infrastructure/queue/app-queue.service.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/infrastructure/queue/queue.module.ts
Normal file
9
src/infrastructure/queue/queue.module.ts
Normal file
@@ -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 {}
|
||||
9
src/infrastructure/redis/redis.module.ts
Normal file
9
src/infrastructure/redis/redis.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
79
src/infrastructure/redis/redis.service.ts
Normal file
79
src/infrastructure/redis/redis.service.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
45
src/infrastructure/socket/redis-io.adapter.ts
Normal file
45
src/infrastructure/socket/redis-io.adapter.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
210
src/infrastructure/storage/managed-storage.service.ts
Normal file
210
src/infrastructure/storage/managed-storage.service.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
9
src/infrastructure/storage/storage.module.ts
Normal file
9
src/infrastructure/storage/storage.module.ts
Normal file
@@ -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 {}
|
||||
47
src/main.ts
47
src/main.ts
@@ -9,14 +9,27 @@ import { existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
||||
import { AppLoggerService } from './infrastructure/logging/app-logger.service';
|
||||
import { RedisService } from './infrastructure/redis/redis.service';
|
||||
import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
const configService = app.get(ConfigService);
|
||||
const appLogger = app.get(AppLoggerService);
|
||||
app.useLogger(appLogger);
|
||||
const corsOrigins = configService.get<string[]>('cors.origins', []);
|
||||
const uploadsDir = join(process.cwd(), 'uploads');
|
||||
const storageProvider = configService.get<string>('storage.provider', { infer: true }) ?? 'local';
|
||||
const storageBasePath =
|
||||
(configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads').replace(
|
||||
/^\/+|\/+$/g,
|
||||
'',
|
||||
);
|
||||
const publicBaseUrl =
|
||||
(configService.get<string>('publicBaseUrl', { infer: true }) ?? '').replace(/\/$/, '');
|
||||
const uploadsDir = join(process.cwd(), storageBasePath);
|
||||
|
||||
if (!existsSync(uploadsDir)) {
|
||||
if (storageProvider === 'local' && !existsSync(uploadsDir)) {
|
||||
mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -43,15 +56,13 @@ async function bootstrap(): Promise<void> {
|
||||
res.setHeader('x-request-id', requestId);
|
||||
|
||||
res.on('finish', () => {
|
||||
const log = {
|
||||
level: 'info',
|
||||
appLogger.logHttp({
|
||||
requestId,
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
console.log(JSON.stringify(log));
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
@@ -62,7 +73,18 @@ async function bootstrap(): Promise<void> {
|
||||
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
||||
}
|
||||
|
||||
app.use('/uploads', express.static(uploadsDir));
|
||||
if (storageProvider === 'local') {
|
||||
app.use(`/${storageBasePath}`, express.static(uploadsDir));
|
||||
}
|
||||
|
||||
const redisEnabled = configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
|
||||
const socketAdapterEnabled =
|
||||
configService.get<boolean>('redis.socketAdapterEnabled', { infer: true }) ?? false;
|
||||
if (redisEnabled && socketAdapterEnabled) {
|
||||
const redisIoAdapter = new RedisIoAdapter(app, app.get(RedisService));
|
||||
await redisIoAdapter.connectToRedis();
|
||||
app.useWebSocketAdapter(redisIoAdapter);
|
||||
}
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle(configService.get<string>('swagger.title', 'Oudelaa API'))
|
||||
@@ -78,7 +100,16 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
const port = configService.get<number>('port', 4000);
|
||||
const host = configService.get<string>('host', '0.0.0.0');
|
||||
if (host === '0.0.0.0' && publicBaseUrl.includes('localhost')) {
|
||||
appLogger.warn(
|
||||
`PUBLIC_BASE_URL is set to "${publicBaseUrl}". Mobile devices on the LAN will not be able to open uploaded files until this is changed to your machine IP, for example http://192.168.x.x:${port}`,
|
||||
'Bootstrap',
|
||||
);
|
||||
}
|
||||
|
||||
await app.listen(port, host);
|
||||
appLogger.log(`Server listening on http://${host}:${port}`, 'Bootstrap');
|
||||
appLogger.log(`Resolved PUBLIC_BASE_URL=${publicBaseUrl || `http://localhost:${port}`}`, 'Bootstrap');
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
22
src/modules/audit/audit.controller.ts
Normal file
22
src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditQueryDto } from './dto/audit-query.dto';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
|
||||
@ApiTags('Audit')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@Controller('audit/superadmin')
|
||||
export class AuditController {
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
@Get('logs')
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.AUDIT_READ)
|
||||
async listLogs(@Query() query: AuditQueryDto) {
|
||||
return this.auditService.listSuperAdminLogs(query);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { AuditController } from './audit.controller';
|
||||
import { AuditRepository } from './audit.repository';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
|
||||
@@ -13,6 +14,7 @@ import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [AuditController],
|
||||
providers: [AuditRepository, AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
|
||||
@@ -26,4 +26,17 @@ export class AuditRepository {
|
||||
metadata: payload.metadata ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
async findMany(
|
||||
filter: Record<string, unknown>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<AuditLogDocument[]> {
|
||||
return this.auditModel.find(filter).sort(sort).skip(skip).limit(limit).exec();
|
||||
}
|
||||
|
||||
async count(filter: Record<string, unknown>): Promise<number> {
|
||||
return this.auditModel.countDocuments(filter).exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { AuditRepository } from './audit.repository';
|
||||
import { AuditQueryDto } from './dto/audit-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
@@ -21,4 +24,41 @@ export class AuditService {
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
async listSuperAdminLogs(query: AuditQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
if (query.q?.trim()) {
|
||||
filter.$or = [
|
||||
{ action: { $regex: query.q.trim(), $options: 'i' } },
|
||||
{ targetType: { $regex: query.q.trim(), $options: 'i' } },
|
||||
{ targetId: { $regex: query.q.trim(), $options: 'i' } },
|
||||
{ actorIdentifier: { $regex: query.q.trim(), $options: 'i' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (query.actorType) {
|
||||
filter.actorType = query.actorType;
|
||||
}
|
||||
|
||||
if (query.targetType?.trim()) {
|
||||
filter.targetType = query.targetType.trim();
|
||||
}
|
||||
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
const [items, total] = await Promise.all([
|
||||
this.auditRepository.findMany(filter, skip, limit, sort),
|
||||
this.auditRepository.count(filter),
|
||||
]);
|
||||
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
offset: skip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
22
src/modules/audit/dto/audit-query.dto.ts
Normal file
22
src/modules/audit/dto/audit-query.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
const ACTOR_TYPES = ['user', 'superadmin', 'system'] as const;
|
||||
|
||||
export class AuditQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search in action, targetType, targetId, or actorIdentifier' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ACTOR_TYPES })
|
||||
@IsOptional()
|
||||
@IsEnum(ACTOR_TYPES)
|
||||
actorType?: (typeof ACTOR_TYPES)[number];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
targetType?: string;
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
@@ -18,6 +21,7 @@ import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
||||
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
||||
import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
@@ -151,4 +155,24 @@ export class AuthController {
|
||||
await this.authService.revokeUserSession(user.sub, jti);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE)
|
||||
@Get('superadmin/sessions')
|
||||
async listSuperAdminSessions(@CurrentUser() user: JwtPayload) {
|
||||
return this.authService.listSuperAdminSessions(user.email ?? '');
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE)
|
||||
@Post('superadmin/sessions/:sessionId/revoke')
|
||||
async revokeSuperAdminSession(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('sessionId') sessionId: string,
|
||||
) {
|
||||
await this.authService.revokeSuperAdminSession(user.email ?? '', sessionId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,11 +91,13 @@ export class AuthRepository {
|
||||
|
||||
async createSuperAdminRefreshToken(
|
||||
adminEmail: string,
|
||||
jti: string,
|
||||
tokenHash: string,
|
||||
expiresAt: Date,
|
||||
): Promise<void> {
|
||||
await this.superAdminRefreshTokenModel.create({
|
||||
adminEmail: adminEmail.toLowerCase(),
|
||||
jti,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
});
|
||||
@@ -108,12 +110,28 @@ export class AuthRepository {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findActiveSuperAdminTokenByJti(
|
||||
adminEmail: string,
|
||||
jti: string,
|
||||
): Promise<SuperAdminRefreshTokenDocument | null> {
|
||||
return this.superAdminRefreshTokenModel
|
||||
.findOne({ adminEmail: adminEmail.toLowerCase(), jti, revoked: false })
|
||||
.select('+tokenHash')
|
||||
.exec();
|
||||
}
|
||||
|
||||
async revokeAllSuperAdminTokens(adminEmail: string): Promise<void> {
|
||||
await this.superAdminRefreshTokenModel
|
||||
.updateMany({ adminEmail: adminEmail.toLowerCase(), revoked: false }, { revoked: true })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async revokeSuperAdminTokenByJti(adminEmail: string, jti: string): Promise<void> {
|
||||
await this.superAdminRefreshTokenModel
|
||||
.updateOne({ adminEmail: adminEmail.toLowerCase(), jti, revoked: false }, { revoked: true })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> {
|
||||
await this.superAdminRefreshTokenModel
|
||||
.deleteMany({
|
||||
@@ -123,6 +141,34 @@ export class AuthRepository {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async listSuperAdminSessions(adminEmail: string): Promise<SuperAdminRefreshTokenDocument[]> {
|
||||
return this.superAdminRefreshTokenModel
|
||||
.find({
|
||||
adminEmail: adminEmail.toLowerCase(),
|
||||
revoked: false,
|
||||
expiresAt: { $gt: new Date() },
|
||||
})
|
||||
.select('adminEmail jti expiresAt createdAt')
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async revokeSuperAdminSessionById(adminEmail: string, sessionId: string): Promise<boolean> {
|
||||
const updated = await this.superAdminRefreshTokenModel
|
||||
.findOneAndUpdate(
|
||||
{
|
||||
_id: new Types.ObjectId(sessionId),
|
||||
adminEmail: adminEmail.toLowerCase(),
|
||||
revoked: false,
|
||||
},
|
||||
{ revoked: true },
|
||||
{ new: false },
|
||||
)
|
||||
.exec();
|
||||
|
||||
return !!updated;
|
||||
}
|
||||
|
||||
async invalidateActivePasswordResetCodes(userId: string): Promise<void> {
|
||||
await this.passwordResetCodeModel
|
||||
.updateMany(
|
||||
|
||||
@@ -9,7 +9,12 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomBytes, randomInt, randomUUID } from 'crypto';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { compareHash, hashValue } from '../../common/utils/hash.util';
|
||||
import {
|
||||
compareHash,
|
||||
compareStoredHighEntropyValue,
|
||||
hashHighEntropyValue,
|
||||
hashValue,
|
||||
} from '../../common/utils/hash.util';
|
||||
import { EmailService } from '../email/email.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
@@ -25,6 +30,7 @@ import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import { AuthResult, TokenPair } from './types/token-pair.type';
|
||||
import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -56,16 +62,10 @@ export class AuthService {
|
||||
username: generatedUsername,
|
||||
password: passwordHash,
|
||||
});
|
||||
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
||||
const response: { message: string; email: string; debugCode?: string } = {
|
||||
message: 'Registration successful. Verify your email with the code sent.',
|
||||
return {
|
||||
message: 'Registration successful. Account is pending SuperAdmin verification.',
|
||||
email: user.email,
|
||||
};
|
||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||
if (nodeEnv !== 'production') {
|
||||
response.debugCode = code;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> {
|
||||
@@ -84,16 +84,10 @@ export class AuthService {
|
||||
password: passwordHash,
|
||||
});
|
||||
|
||||
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
||||
const response: { message: string; email: string; debugCode?: string } = {
|
||||
message: 'Registration successful. Verify your email with the code sent.',
|
||||
return {
|
||||
message: 'Registration successful. Account is pending SuperAdmin verification.',
|
||||
email: user.email,
|
||||
};
|
||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||
if (nodeEnv !== 'production') {
|
||||
response.debugCode = code;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async login(dto: LoginDto): Promise<AuthResult> {
|
||||
@@ -105,7 +99,7 @@ export class AuthService {
|
||||
throw new ForbiddenException('Account is disabled');
|
||||
}
|
||||
if (!user.isVerified) {
|
||||
throw new ForbiddenException('Email not verified');
|
||||
throw new ForbiddenException('Account is pending SuperAdmin verification');
|
||||
}
|
||||
|
||||
const isMatch = await compareHash(dto.password, user.password);
|
||||
@@ -123,71 +117,20 @@ export class AuthService {
|
||||
): Promise<{ message: string; debugCode?: string }> {
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||
const message = 'If this email exists, a verification code was sent';
|
||||
const message = 'Account verification is managed by SuperAdmin';
|
||||
if (!user || user.isDisabled) {
|
||||
return { message };
|
||||
}
|
||||
if (user.isVerified) {
|
||||
return { message: 'Email is already verified' };
|
||||
return { message: 'Account is already verified' };
|
||||
}
|
||||
|
||||
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
||||
const response: { message: string; debugCode?: string } = { message };
|
||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||
if (nodeEnv !== 'production') {
|
||||
response.debugCode = code;
|
||||
}
|
||||
return response;
|
||||
return { message: 'Account is pending SuperAdmin verification' };
|
||||
}
|
||||
|
||||
async verifyEmail(dto: VerifyEmailDto): Promise<AuthResult & { message: string }> {
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||
if (!user || user.isDisabled) {
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
if (user.isVerified) {
|
||||
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
||||
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||
return {
|
||||
message: 'Email already verified',
|
||||
...tokens,
|
||||
user: safeUser.toObject() as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
const codeRecord = await this.authRepository.findLatestActiveEmailVerificationCode(user.id);
|
||||
if (!codeRecord) {
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
const maxAttempts = this.configService.get<number>('emailVerification.maxAttempts', { infer: true });
|
||||
if (codeRecord.attempts >= maxAttempts) {
|
||||
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
||||
throw new UnauthorizedException('Verification code attempts exceeded');
|
||||
}
|
||||
|
||||
const isMatch = await compareHash(dto.code, codeRecord.codeHash);
|
||||
if (!isMatch) {
|
||||
await this.authRepository.incrementEmailVerificationAttempts(codeRecord.id);
|
||||
if (codeRecord.attempts + 1 >= maxAttempts) {
|
||||
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
||||
}
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
await this.usersService.markEmailVerified(user.id);
|
||||
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
||||
await this.authRepository.markAllEmailVerificationCodesUsedByUser(user.id);
|
||||
|
||||
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||
const tokens = await this.generateAndStoreTokenPair(safeUser.id, safeUser.username, safeUser.role ?? 'user');
|
||||
|
||||
async verifyEmail(_dto: VerifyEmailDto): Promise<{ message: string }> {
|
||||
return {
|
||||
message: 'Email verified successfully',
|
||||
...tokens,
|
||||
user: safeUser.toObject() as unknown as Record<string, unknown>,
|
||||
message: 'Account verification is managed by SuperAdmin',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,7 +152,11 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Refresh token reuse detected');
|
||||
}
|
||||
|
||||
const isMatch = await compareHash(dto.refreshToken, tokenRecord.tokenHash);
|
||||
const isMatch = await compareStoredHighEntropyValue(
|
||||
dto.refreshToken,
|
||||
tokenRecord.tokenHash,
|
||||
this.getRefreshTokenHashSecret(),
|
||||
);
|
||||
if (!isMatch) {
|
||||
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
|
||||
throw new UnauthorizedException('Refresh token reuse detected');
|
||||
@@ -265,7 +212,7 @@ export class AuthService {
|
||||
email: googleUser.email,
|
||||
password: passwordHash,
|
||||
avatar: googleUser.avatar ?? '',
|
||||
isVerified: true,
|
||||
isVerified: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,6 +224,10 @@ export class AuthService {
|
||||
throw new ForbiddenException('Account is disabled');
|
||||
}
|
||||
|
||||
if (!user.isVerified) {
|
||||
throw new ForbiddenException('Account is pending SuperAdmin verification');
|
||||
}
|
||||
|
||||
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
||||
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||
return { ...tokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
|
||||
@@ -345,43 +296,51 @@ export class AuthService {
|
||||
refreshToken: string;
|
||||
superAdmin: { email: string };
|
||||
}> {
|
||||
const decoded = this.jwtService.verify<{ email: string; tokenType: string }>(dto.refreshToken, {
|
||||
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||
});
|
||||
const decoded = this.jwtService.verify<{ email: string; tokenType: string; jti?: string }>(
|
||||
dto.refreshToken,
|
||||
{
|
||||
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||
},
|
||||
);
|
||||
|
||||
if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email) {
|
||||
if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email || !decoded.jti) {
|
||||
throw new UnauthorizedException('Invalid superadmin refresh token');
|
||||
}
|
||||
|
||||
const activeTokens = await this.authRepository.findActiveSuperAdminTokens(decoded.email);
|
||||
if (!activeTokens.length) {
|
||||
throw new UnauthorizedException('Invalid superadmin refresh token');
|
||||
const tokenRecord = await this.authRepository.findActiveSuperAdminTokenByJti(
|
||||
decoded.email,
|
||||
decoded.jti,
|
||||
);
|
||||
if (!tokenRecord) {
|
||||
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||
throw new UnauthorizedException('Superadmin refresh token reuse detected');
|
||||
}
|
||||
|
||||
let validTokenFound = false;
|
||||
for (const token of activeTokens) {
|
||||
const isMatch = await compareHash(dto.refreshToken, token.tokenHash);
|
||||
if (isMatch) {
|
||||
validTokenFound = true;
|
||||
break;
|
||||
}
|
||||
const isMatch = await compareStoredHighEntropyValue(
|
||||
dto.refreshToken,
|
||||
tokenRecord.tokenHash,
|
||||
this.getRefreshTokenHashSecret(),
|
||||
);
|
||||
if (!isMatch) {
|
||||
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||
throw new UnauthorizedException('Superadmin refresh token reuse detected');
|
||||
}
|
||||
|
||||
if (!validTokenFound) {
|
||||
throw new UnauthorizedException('Invalid superadmin refresh token');
|
||||
}
|
||||
|
||||
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||
await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti);
|
||||
const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email);
|
||||
return { ...tokens, superAdmin: { email: decoded.email } };
|
||||
}
|
||||
|
||||
async superAdminLogout(dto: RefreshTokenDto): Promise<void> {
|
||||
try {
|
||||
const decoded = this.jwtService.verify<{ email: string }>(dto.refreshToken, {
|
||||
const decoded = this.jwtService.verify<{ email: string; jti?: string }>(dto.refreshToken, {
|
||||
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||
});
|
||||
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||
if (decoded.jti) {
|
||||
await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti);
|
||||
} else {
|
||||
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||
}
|
||||
await this.authRepository.removeExpiredAndRevokedSuperAdmin(decoded.email);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid superadmin refresh token');
|
||||
@@ -403,6 +362,27 @@ export class AuthService {
|
||||
await this.authRepository.revokeUserTokenByJti(userId, jti);
|
||||
}
|
||||
|
||||
async listSuperAdminSessions(
|
||||
adminEmail: string,
|
||||
): Promise<{ items: Array<{ id: string; jti: string; createdAt: Date; expiresAt: Date }> }> {
|
||||
const sessions = await this.authRepository.listSuperAdminSessions(adminEmail);
|
||||
return {
|
||||
items: sessions.map((session) => ({
|
||||
id: session.id,
|
||||
jti: session.jti,
|
||||
createdAt: (session as unknown as { createdAt: Date }).createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async revokeSuperAdminSession(adminEmail: string, sessionId: string): Promise<void> {
|
||||
const revoked = await this.authRepository.revokeSuperAdminSessionById(adminEmail, sessionId);
|
||||
if (!revoked) {
|
||||
throw new BadRequestException('Superadmin session not found');
|
||||
}
|
||||
}
|
||||
|
||||
async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string; debugCode?: string }> {
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||
@@ -549,8 +529,7 @@ export class AuthService {
|
||||
),
|
||||
]);
|
||||
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const tokenHash = await hashValue(refreshToken, saltRounds);
|
||||
const tokenHash = hashHighEntropyValue(refreshToken, this.getRefreshTokenHashSecret());
|
||||
|
||||
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn', {
|
||||
infer: true,
|
||||
@@ -563,6 +542,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
private async generateAndStoreSuperAdminTokenPair(adminEmail: string): Promise<TokenPair> {
|
||||
const permissions = this.getSuperAdminPermissions();
|
||||
const refreshJti = randomUUID();
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(
|
||||
{
|
||||
@@ -571,6 +552,7 @@ export class AuthService {
|
||||
email: adminEmail.toLowerCase(),
|
||||
role: 'superadmin',
|
||||
tokenType: 'superadmin_access',
|
||||
permissions,
|
||||
},
|
||||
{
|
||||
secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }),
|
||||
@@ -584,6 +566,7 @@ export class AuthService {
|
||||
email: adminEmail.toLowerCase(),
|
||||
role: 'superadmin',
|
||||
tokenType: 'superadmin_refresh',
|
||||
jti: refreshJti,
|
||||
},
|
||||
{
|
||||
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||
@@ -592,8 +575,7 @@ export class AuthService {
|
||||
),
|
||||
]);
|
||||
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const tokenHash = await hashValue(refreshToken, saltRounds);
|
||||
const tokenHash = hashHighEntropyValue(refreshToken, this.getRefreshTokenHashSecret());
|
||||
const refreshExpiresIn = this.configService.get<string>('superAdmin.refreshExpiresIn', {
|
||||
infer: true,
|
||||
});
|
||||
@@ -601,6 +583,7 @@ export class AuthService {
|
||||
|
||||
await this.authRepository.createSuperAdminRefreshToken(
|
||||
adminEmail,
|
||||
refreshJti,
|
||||
tokenHash,
|
||||
new Date(Date.now() + refreshExpiresInMs),
|
||||
);
|
||||
@@ -647,6 +630,18 @@ export class AuthService {
|
||||
return String(randomInt(100000, 1000000));
|
||||
}
|
||||
|
||||
private getRefreshTokenHashSecret(): string {
|
||||
return (
|
||||
this.configService.get<string>('security.refreshTokenHashSecret', { infer: true }) ??
|
||||
this.configService.get<string>('jwt.refreshSecret', { infer: true }) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
private getSuperAdminPermissions(): string[] {
|
||||
return [...DEFAULT_SUPERADMIN_PERMISSIONS];
|
||||
}
|
||||
|
||||
private async issueEmailVerificationCode(userId: string, email: string): Promise<string> {
|
||||
const code = this.generateResetCode();
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'class-validator';
|
||||
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
||||
import { MusicRole } from '../../../common/enums/music-role.enum';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'john@example.com' })
|
||||
@@ -78,6 +79,7 @@ export class RegisterDto {
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isPrivate?: boolean;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export class EmailVerificationCode {
|
||||
@Prop({ required: true, select: false })
|
||||
codeHash!: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
@Prop({ required: true })
|
||||
expiresAt!: Date;
|
||||
|
||||
@Prop({ default: 0, min: 0 })
|
||||
|
||||
@@ -12,7 +12,7 @@ export class PasswordResetCode {
|
||||
@Prop({ required: true, select: false })
|
||||
codeHash!: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
@Prop({ required: true })
|
||||
expiresAt!: Date;
|
||||
|
||||
@Prop({ default: 0, min: 0 })
|
||||
|
||||
@@ -8,6 +8,9 @@ export class SuperAdminRefreshToken {
|
||||
@Prop({ required: true, trim: true, lowercase: true, index: true })
|
||||
adminEmail!: string;
|
||||
|
||||
@Prop({ required: true, trim: true, index: true })
|
||||
jti!: string;
|
||||
|
||||
@Prop({ required: true, select: false })
|
||||
tokenHash!: string;
|
||||
|
||||
@@ -20,4 +23,5 @@ export class SuperAdminRefreshToken {
|
||||
|
||||
export const SuperAdminRefreshTokenSchema = SchemaFactory.createForClass(SuperAdminRefreshToken);
|
||||
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, revoked: 1 });
|
||||
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, jti: 1 }, { unique: true, sparse: true });
|
||||
SuperAdminRefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatGateway } from './chat.gateway';
|
||||
@@ -15,6 +16,7 @@ import { Message, MessageSchema } from './schemas/message.schema';
|
||||
imports: [
|
||||
ConfigModule,
|
||||
JwtModule.register({}),
|
||||
NotificationsModule,
|
||||
UsersModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: Conversation.name, schema: ConversationSchema },
|
||||
|
||||
@@ -51,11 +51,16 @@ export class ChatRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findConversationsForUser(userId: string, skip: number, limit: number): Promise<ConversationDocument[]> {
|
||||
async findConversationsForUser(
|
||||
userId: string,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { lastMessageAt: -1, updatedAt: -1 },
|
||||
): Promise<ConversationDocument[]> {
|
||||
return this.conversationModel
|
||||
.find({ participantIds: new Types.ObjectId(userId) })
|
||||
.populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ lastMessageAt: -1, updatedAt: -1 })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
@@ -83,11 +88,16 @@ export class ChatRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findMessages(conversationId: string, skip: number, limit: number): Promise<MessageDocument[]> {
|
||||
async findMessages(
|
||||
conversationId: string,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<MessageDocument[]> {
|
||||
return this.messageModel
|
||||
.find({ conversationId: new Types.ObjectId(conversationId) })
|
||||
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
|
||||
.sort({ createdAt: -1 })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { CreateConversationDto } from './dto/create-conversation.dto';
|
||||
import { MessageQueryDto } from './dto/message-query.dto';
|
||||
@@ -9,9 +12,12 @@ import { ChatRepository } from './chat.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
private readonly logger = new Logger(ChatService.name);
|
||||
|
||||
constructor(
|
||||
private readonly chatRepository: ChatRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async createConversation(currentUserId: string, dto: CreateConversationDto) {
|
||||
@@ -61,9 +67,13 @@ export class ChatService {
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
const direction = resolveMongoSortDirection(query.sortOrder);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.chatRepository.findConversationsForUser(currentUserId, skip, limit),
|
||||
this.chatRepository.findConversationsForUser(currentUserId, skip, limit, {
|
||||
lastMessageAt: direction,
|
||||
updatedAt: direction,
|
||||
}),
|
||||
this.chatRepository.countConversationsForUser(currentUserId),
|
||||
]);
|
||||
|
||||
@@ -78,14 +88,15 @@ export class ChatService {
|
||||
const nextOffset = skip + mappedItems.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items: mappedItems,
|
||||
return buildPaginatedResponse(mappedItems, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
offset: skip,
|
||||
currentCursor: query.cursor ?? null,
|
||||
nextCursor,
|
||||
};
|
||||
mode: 'cursor',
|
||||
});
|
||||
}
|
||||
|
||||
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
|
||||
@@ -94,9 +105,10 @@ export class ChatService {
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.chatRepository.findMessages(conversation.id, skip, limit),
|
||||
this.chatRepository.findMessages(conversation.id, skip, limit, sort),
|
||||
this.chatRepository.countMessages(conversation.id),
|
||||
]);
|
||||
|
||||
@@ -104,14 +116,15 @@ export class ChatService {
|
||||
const nextOffset = skip + items.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items,
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
offset: skip,
|
||||
currentCursor: query.cursor ?? null,
|
||||
nextCursor,
|
||||
};
|
||||
mode: 'cursor',
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(currentUserId: string, dto: SendMessageDto) {
|
||||
@@ -144,6 +157,12 @@ export class ChatService {
|
||||
currentUserId,
|
||||
preview,
|
||||
);
|
||||
await this.dispatchMessageNotifications(
|
||||
currentUserId,
|
||||
conversation.participantIds.map((id) => id.toString()),
|
||||
conversation.id,
|
||||
preview,
|
||||
);
|
||||
|
||||
return message;
|
||||
}
|
||||
@@ -247,4 +266,32 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchMessageNotifications(
|
||||
actorId: string,
|
||||
participantIds: string[],
|
||||
conversationId: string,
|
||||
previewText: string,
|
||||
): Promise<void> {
|
||||
for (const recipientId of participantIds) {
|
||||
if (recipientId === actorId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.notificationsService.createMessageNotification(
|
||||
actorId,
|
||||
recipientId,
|
||||
conversationId,
|
||||
previewText.slice(0, 160),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Message notification failed for actor=${actorId} recipient=${recipientId}: ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export class CreateConversationDto {
|
||||
@IsArray()
|
||||
@@ -8,6 +10,7 @@ export class CreateConversationDto {
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isGroup?: boolean;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { resolveManagedFileUrl } from '../../../common/utils/public-url.util';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type MessageDocument = HydratedDocument<Message>;
|
||||
@@ -31,3 +32,11 @@ export class Message {
|
||||
export const MessageSchema = SchemaFactory.createForClass(Message);
|
||||
MessageSchema.index({ conversationId: 1, createdAt: -1 });
|
||||
MessageSchema.index({ conversationId: 1, isUnsent: 1, createdAt: -1 });
|
||||
|
||||
const transformManagedMessageFiles = (_doc: unknown, ret: any) => {
|
||||
ret.mediaUrl = resolveManagedFileUrl(ret.mediaUrl);
|
||||
return ret;
|
||||
};
|
||||
|
||||
MessageSchema.set('toJSON', { transform: transformManagedMessageFiles });
|
||||
MessageSchema.set('toObject', { transform: transformManagedMessageFiles });
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
|
||||
import { CommentQueryDto } from './dto/comment-query.dto';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CommentsService } from './comments.service';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
|
||||
@ApiTags('Comments')
|
||||
@Controller('comments')
|
||||
@@ -34,6 +38,14 @@ export class CommentsController {
|
||||
return this.commentsService.findReplies(commentId, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
|
||||
@Get('admin')
|
||||
async adminList(@Query() query: AdminCommentQueryDto) {
|
||||
return this.commentsService.findPlatformComments(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':commentId')
|
||||
@@ -42,7 +54,8 @@ export class CommentsController {
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
|
||||
@Delete('admin/:commentId')
|
||||
async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
||||
return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PostsModule } from '../posts/posts.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { Comment, CommentSchema } from './schemas/comment.schema';
|
||||
import { CommentsController } from './comments.controller';
|
||||
import { CommentsService } from './comments.service';
|
||||
@@ -12,6 +14,8 @@ import { CommentsRepository } from './comments.repository';
|
||||
AuditModule,
|
||||
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
|
||||
PostsModule,
|
||||
NotificationsModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [CommentsController],
|
||||
providers: [CommentsService, CommentsRepository],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
|
||||
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||
import { Comment, CommentDocument } from './schemas/comment.schema';
|
||||
|
||||
@Injectable()
|
||||
@@ -8,6 +9,14 @@ export class CommentsRepository {
|
||||
constructor(@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>) {}
|
||||
|
||||
private withActiveFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
|
||||
return {
|
||||
...filter,
|
||||
isDeleted: { $ne: true },
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
};
|
||||
}
|
||||
|
||||
private withAdminFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
|
||||
return {
|
||||
...filter,
|
||||
isDeleted: { $ne: true },
|
||||
@@ -15,15 +24,24 @@ export class CommentsRepository {
|
||||
}
|
||||
|
||||
async create(
|
||||
payload: { postId: string; authorId: string; content: string; parentCommentId?: string },
|
||||
payload: {
|
||||
postId: string;
|
||||
authorId: string;
|
||||
content: string;
|
||||
mentionUsernames?: string[];
|
||||
parentCommentId?: string;
|
||||
},
|
||||
session?: ClientSession,
|
||||
) {
|
||||
return this.commentModel.create({
|
||||
const doc = new this.commentModel({
|
||||
postId: new Types.ObjectId(payload.postId),
|
||||
authorId: new Types.ObjectId(payload.authorId),
|
||||
content: payload.content,
|
||||
mentionUsernames: payload.mentionUsernames ?? [],
|
||||
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
|
||||
}, { session });
|
||||
});
|
||||
|
||||
return session ? doc.save({ session }) : doc.save();
|
||||
}
|
||||
|
||||
async findById(commentId: string): Promise<CommentDocument | null> {
|
||||
@@ -55,11 +73,31 @@ export class CommentsRepository {
|
||||
return !!updated;
|
||||
}
|
||||
|
||||
async findMany(filter: FilterQuery<CommentDocument>, skip: number, limit: number) {
|
||||
async findMany(
|
||||
filter: FilterQuery<CommentDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
) {
|
||||
return this.commentModel
|
||||
.find(this.withActiveFilter(filter))
|
||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||
.sort({ createdAt: -1 })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findManyAdmin(
|
||||
filter: FilterQuery<CommentDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
) {
|
||||
return this.commentModel
|
||||
.find(this.withAdminFilter(filter))
|
||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
@@ -69,6 +107,31 @@ export class CommentsRepository {
|
||||
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
|
||||
}
|
||||
|
||||
async countAdmin(filter: FilterQuery<CommentDocument>): Promise<number> {
|
||||
return this.commentModel.countDocuments(this.withAdminFilter(filter)).exec();
|
||||
}
|
||||
|
||||
async updateModerationStatus(
|
||||
commentId: string,
|
||||
payload: Pick<Comment, 'moderationStatus' | 'moderationReason'>,
|
||||
): Promise<CommentDocument | null> {
|
||||
if (!Types.ObjectId.isValid(commentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.commentModel
|
||||
.findByIdAndUpdate(
|
||||
commentId,
|
||||
{
|
||||
moderationStatus: payload.moderationStatus,
|
||||
moderationReason: payload.moderationReason,
|
||||
},
|
||||
{ new: true },
|
||||
)
|
||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countByPost(postId: string): Promise<number> {
|
||||
return this.commentModel
|
||||
.countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } })
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PostsRepository } from '../posts/posts.repository';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
|
||||
import { CommentQueryDto } from './dto/comment-query.dto';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CommentsRepository } from './comments.repository';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
private readonly logger = new Logger(CommentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly commentsRepository: CommentsRepository,
|
||||
private readonly postsRepository: PostsRepository,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly feedVersionService: FeedVersionService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateCommentDto) {
|
||||
@@ -19,20 +32,42 @@ export class CommentsService {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
|
||||
let parentRecipientId = '';
|
||||
if (dto.parentCommentId) {
|
||||
const parent = await this.commentsRepository.findById(dto.parentCommentId);
|
||||
if (!parent || parent.postId.toString() !== dto.postId) {
|
||||
throw new NotFoundException('Parent comment not found');
|
||||
}
|
||||
parentRecipientId = parent.authorId.toString();
|
||||
}
|
||||
|
||||
const content = dto.content.trim();
|
||||
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, content, userId);
|
||||
const comment = await this.commentsRepository.create({
|
||||
postId: dto.postId,
|
||||
authorId: userId,
|
||||
content: dto.content,
|
||||
content,
|
||||
mentionUsernames: mentionResolution.mentionUsernames,
|
||||
parentCommentId: dto.parentCommentId,
|
||||
});
|
||||
await this.syncCommentsCount(dto.postId);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
const postAuthorId = this.extractEntityId(post.authorId);
|
||||
const previewText = content.slice(0, 160);
|
||||
const commentNotificationRecipients = await this.dispatchCommentNotifications(
|
||||
userId,
|
||||
postAuthorId,
|
||||
parentRecipientId,
|
||||
dto.postId,
|
||||
previewText,
|
||||
);
|
||||
await this.notifyMentionedUsers(
|
||||
userId,
|
||||
dto.postId,
|
||||
mentionResolution.mentionedUsers,
|
||||
previewText,
|
||||
commentNotificationRecipients,
|
||||
);
|
||||
return comment;
|
||||
}
|
||||
|
||||
@@ -48,6 +83,7 @@ export class CommentsService {
|
||||
|
||||
await this.commentsRepository.deleteById(commentId, userId);
|
||||
await this.syncCommentsCount(comment.postId.toString());
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -59,6 +95,7 @@ export class CommentsService {
|
||||
|
||||
await this.commentsRepository.deleteById(commentId, superAdminIdentifier);
|
||||
await this.syncCommentsCount(comment.postId.toString());
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
await this.auditService.logSuperAdminAction(
|
||||
superAdminIdentifier,
|
||||
'comment_delete',
|
||||
@@ -70,45 +107,281 @@ export class CommentsService {
|
||||
}
|
||||
|
||||
async findByPost(postId: string, query: CommentQueryDto) {
|
||||
if (!Types.ObjectId.isValid(postId)) {
|
||||
throw new BadRequestException('Invalid post id');
|
||||
}
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const postObjectId = new Types.ObjectId(postId);
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.commentsRepository.findMany({ postId, parentCommentId: { $exists: false } }, skip, limit),
|
||||
this.commentsRepository.count({ postId, parentCommentId: { $exists: false } }),
|
||||
this.commentsRepository.findMany(
|
||||
{
|
||||
postId: postObjectId,
|
||||
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
|
||||
},
|
||||
skip,
|
||||
limit,
|
||||
sort,
|
||||
),
|
||||
this.commentsRepository.count({
|
||||
postId: postObjectId,
|
||||
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
offset: skip,
|
||||
});
|
||||
}
|
||||
|
||||
async findReplies(parentCommentId: string, query: CommentQueryDto) {
|
||||
if (!Types.ObjectId.isValid(parentCommentId)) {
|
||||
throw new BadRequestException('Invalid parent comment id');
|
||||
}
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const parentObjectId = new Types.ObjectId(parentCommentId);
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.commentsRepository.findMany({ parentCommentId }, skip, limit),
|
||||
this.commentsRepository.count({ parentCommentId }),
|
||||
this.commentsRepository.findMany({ parentCommentId: parentObjectId }, skip, limit, sort),
|
||||
this.commentsRepository.count({ parentCommentId: parentObjectId }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
offset: skip,
|
||||
});
|
||||
}
|
||||
|
||||
async findPlatformComments(query: AdminCommentQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
if (query.postId) {
|
||||
filter.postId = new Types.ObjectId(query.postId);
|
||||
}
|
||||
if (query.authorId) {
|
||||
filter.authorId = new Types.ObjectId(query.authorId);
|
||||
}
|
||||
if (query.q?.trim()) {
|
||||
filter.content = { $regex: query.q.trim(), $options: 'i' };
|
||||
}
|
||||
if (query.moderationStatus) {
|
||||
filter.moderationStatus = query.moderationStatus;
|
||||
}
|
||||
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
const [items, total] = await Promise.all([
|
||||
this.commentsRepository.findManyAdmin(filter, skip, limit, sort),
|
||||
this.commentsRepository.countAdmin(filter),
|
||||
]);
|
||||
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
offset: skip,
|
||||
});
|
||||
}
|
||||
|
||||
async updateModerationStatusBySuperAdmin(
|
||||
superAdminIdentifier: string,
|
||||
commentId: string,
|
||||
dto: { status: ModerationStatus; reason?: string },
|
||||
) {
|
||||
const comment = await this.commentsRepository.findById(commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
const updated = await this.commentsRepository.updateModerationStatus(commentId, {
|
||||
moderationStatus: dto.status,
|
||||
moderationReason: dto.reason?.trim() ?? '',
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
await this.auditService.logSuperAdminAction(
|
||||
superAdminIdentifier,
|
||||
'comment_moderation_status_update',
|
||||
'comment',
|
||||
commentId,
|
||||
{
|
||||
previousStatus: comment.moderationStatus ?? ModerationStatus.ACTIVE,
|
||||
nextStatus: dto.status,
|
||||
reason: dto.reason?.trim() ?? '',
|
||||
},
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async syncCommentsCount(postId: string): Promise<void> {
|
||||
const totalComments = await this.commentsRepository.countByPost(postId);
|
||||
await this.postsRepository.setCommentsCount(postId, totalComments);
|
||||
}
|
||||
|
||||
private async dispatchCommentNotifications(
|
||||
actorId: string,
|
||||
postAuthorId: string,
|
||||
parentRecipientId: string,
|
||||
postId: string,
|
||||
previewText: string,
|
||||
): Promise<Set<string>> {
|
||||
const recipients = new Set<string>();
|
||||
if (postAuthorId && postAuthorId !== actorId) {
|
||||
recipients.add(postAuthorId);
|
||||
}
|
||||
if (parentRecipientId && parentRecipientId !== actorId) {
|
||||
recipients.add(parentRecipientId);
|
||||
}
|
||||
|
||||
for (const recipientId of recipients) {
|
||||
try {
|
||||
await this.notificationsService.createCommentNotification(actorId, recipientId, postId, {
|
||||
resourceType: 'post',
|
||||
previewText,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Comment notification failed for actor=${actorId} recipient=${recipientId}: ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return recipients;
|
||||
}
|
||||
|
||||
private normalizeMentionUsernames(input: string[] = []): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
input
|
||||
.map((username) => username?.trim().replace(/^@+/, '').toLowerCase())
|
||||
.filter((username): username is string => !!username),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private extractMentions(content: string): string[] {
|
||||
const matches = content.match(/@[\p{L}\p{N}_.]+/gu) ?? [];
|
||||
return this.normalizeMentionUsernames(matches.map((item) => item.replace('@', '')));
|
||||
}
|
||||
|
||||
private async resolveMentionTargets(
|
||||
explicitMentionUsernames: string[] | undefined,
|
||||
content: string,
|
||||
authorId: string,
|
||||
): Promise<{
|
||||
mentionUsernames: string[];
|
||||
mentionedUsers: Array<{ id: string; username: string }>;
|
||||
}> {
|
||||
const mergedMentionUsernames = Array.from(
|
||||
new Set([
|
||||
...this.extractMentions(content),
|
||||
...this.normalizeMentionUsernames(explicitMentionUsernames ?? []),
|
||||
]),
|
||||
);
|
||||
|
||||
if (mergedMentionUsernames.length > 30) {
|
||||
throw new BadRequestException('You can mention up to 30 users only');
|
||||
}
|
||||
|
||||
if (!mergedMentionUsernames.length) {
|
||||
return { mentionUsernames: [], mentionedUsers: [] };
|
||||
}
|
||||
|
||||
const users = await this.usersRepository.findByUsernames(mergedMentionUsernames);
|
||||
const userByUsername = new Map(
|
||||
users.map((user) => [user.username.toLowerCase(), { id: user.id, username: user.username.toLowerCase() }]),
|
||||
);
|
||||
|
||||
const mentionedUsers = mergedMentionUsernames
|
||||
.map((username) => userByUsername.get(username))
|
||||
.filter((user): user is { id: string; username: string } => !!user)
|
||||
.filter((user) => user.id !== authorId);
|
||||
|
||||
return {
|
||||
mentionUsernames: mentionedUsers.map((user) => user.username),
|
||||
mentionedUsers,
|
||||
};
|
||||
}
|
||||
|
||||
private async notifyMentionedUsers(
|
||||
actorId: string,
|
||||
postId: string,
|
||||
mentionedUsers: Array<{ id: string; username: string }>,
|
||||
previewText: string,
|
||||
excludedRecipientIds: Set<string>,
|
||||
): Promise<void> {
|
||||
if (!mentionedUsers.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const mentionedUser of mentionedUsers) {
|
||||
if (excludedRecipientIds.has(mentionedUser.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, {
|
||||
resourceType: 'comment',
|
||||
previewText,
|
||||
deepLink: `/posts/${postId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Comment mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractEntityId(value: unknown): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof Types.ObjectId) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const candidate = value as { _id?: unknown; id?: unknown };
|
||||
if (candidate._id instanceof Types.ObjectId) {
|
||||
return candidate._id.toString();
|
||||
}
|
||||
if (typeof candidate._id === 'string') {
|
||||
return candidate._id;
|
||||
}
|
||||
if (typeof candidate.id === 'string') {
|
||||
return candidate.id;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
26
src/modules/comments/dto/admin-comment-query.dto.ts
Normal file
26
src/modules/comments/dto/admin-comment-query.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsMongoId, IsOptional, IsString } from 'class-validator';
|
||||
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
|
||||
import { CommentQueryDto } from './comment-query.dto';
|
||||
|
||||
export class AdminCommentQueryDto extends CommentQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search comment content' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional post filter' })
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
postId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional author filter' })
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
authorId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ModerationStatus, description: 'Optional moderation status filter' })
|
||||
@IsOptional()
|
||||
@IsEnum(ModerationStatus)
|
||||
moderationStatus?: ModerationStatus;
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export class CommentQueryDto extends PaginationQueryDto {}
|
||||
export class CommentQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Use asc to display oldest comments first, or desc for newest first',
|
||||
default: 'desc',
|
||||
})
|
||||
declare sortOrder: PaginationQueryDto['sortOrder'];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsMongoId, IsOptional, IsString, Length } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ArrayMaxSize, IsArray, IsMongoId, IsOptional, IsString, Length } from 'class-validator';
|
||||
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@ApiProperty()
|
||||
@@ -15,4 +17,13 @@ export class CreateCommentDto {
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
parentCommentId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Mention usernames like rami_sabry (max 30)' })
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(30)
|
||||
@IsString({ each: true })
|
||||
@Length(1, 30, { each: true })
|
||||
mentionUsernames?: string[];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
|
||||
import { Post } from '../../posts/schemas/post.schema';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
@@ -19,6 +20,20 @@ export class Comment {
|
||||
@Prop({ required: true, maxlength: 1000 })
|
||||
content!: string;
|
||||
|
||||
@Prop({ type: [String], default: [] })
|
||||
mentionUsernames!: string[];
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
enum: Object.values(ModerationStatus),
|
||||
default: ModerationStatus.ACTIVE,
|
||||
index: true,
|
||||
})
|
||||
moderationStatus!: ModerationStatus;
|
||||
|
||||
@Prop({ default: '', maxlength: 300 })
|
||||
moderationReason!: string;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isDeleted!: boolean;
|
||||
|
||||
@@ -32,3 +47,4 @@ export class Comment {
|
||||
export const CommentSchema = SchemaFactory.createForClass(Comment);
|
||||
CommentSchema.index({ postId: 1, createdAt: -1 });
|
||||
CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 });
|
||||
CommentSchema.index({ moderationStatus: 1, createdAt: -1 });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import { IsBoolean, IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { PostType } from '../../../common/enums/post-type.enum';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export class FeedQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@@ -9,7 +10,7 @@ export class FeedQueryDto extends PaginationQueryDto {
|
||||
preferredPostType?: PostType;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
followingOnly?: boolean;
|
||||
|
||||
@@ -19,4 +20,16 @@ export class FeedQueryDto extends PaginationQueryDto {
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
radiusKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
includeSuggestions?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(2)
|
||||
@Max(10)
|
||||
suggestionInterval?: number;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export class FeedController {
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('trending')
|
||||
async trending(@Query() query: FeedQueryDto) {
|
||||
return this.feedService.getTrending(query);
|
||||
async trending(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
|
||||
return this.feedService.getTrending(user.sub, query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { FollowsModule } from '../follows/follows.module';
|
||||
import { LikesModule } from '../likes/likes.module';
|
||||
import { MarketplaceModule } from '../marketplace/marketplace.module';
|
||||
import { Follow, FollowSchema } from '../follows/schemas/follow.schema';
|
||||
import { Post, PostSchema } from '../posts/schemas/post.schema';
|
||||
import { SavesModule } from '../saves/saves.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { FeedController } from './feed.controller';
|
||||
import { FeedService } from './feed.service';
|
||||
@@ -10,6 +14,10 @@ import { FeedRepository } from './feed.repository';
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
LikesModule,
|
||||
SavesModule,
|
||||
FollowsModule,
|
||||
MarketplaceModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: Post.name, schema: PostSchema },
|
||||
{ name: Follow.name, schema: FollowSchema },
|
||||
|
||||
@@ -42,11 +42,23 @@ export class FeedRepository {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findTrendingPublicPosts(skip: number, limit: number): Promise<PostDocument[]> {
|
||||
async findTrendingPublicPosts(
|
||||
filter: FilterQuery<PostDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
): Promise<PostDocument[]> {
|
||||
return this.postModel
|
||||
.find({ visibility: 'public', isDeleted: { $ne: true } })
|
||||
.find({ ...filter, isDeleted: { $ne: true } })
|
||||
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ likesCount: -1, commentsCount: -1, savesCount: -1, createdAt: -1 })
|
||||
.sort({
|
||||
shareCount: -1,
|
||||
likesCount: -1,
|
||||
commentsCount: -1,
|
||||
savesCount: -1,
|
||||
viewCount: -1,
|
||||
playCount: -1,
|
||||
createdAt: -1,
|
||||
})
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
|
||||
@@ -1,21 +1,97 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Types } from 'mongoose';
|
||||
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||
import { PostType } from '../../common/enums/post-type.enum';
|
||||
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { AppCacheService } from '../../infrastructure/cache/app-cache.service';
|
||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||
import { FollowsService } from '../follows/follows.service';
|
||||
import { LikesRepository } from '../likes/likes.repository';
|
||||
import { MarketplaceService } from '../marketplace/marketplace.service';
|
||||
import { SavesRepository } from '../saves/saves.repository';
|
||||
import { UserDocument } from '../users/schemas/user.schema';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { FeedQueryDto } from './dto/feed-query.dto';
|
||||
import { FeedRepository } from './feed.repository';
|
||||
|
||||
type FeedPostItem = Record<string, unknown> & {
|
||||
feedItemType: 'post';
|
||||
feedScore?: number;
|
||||
likedByMe: boolean;
|
||||
savedByMe: boolean;
|
||||
followingAuthor: boolean;
|
||||
isOwnPost: boolean;
|
||||
canComment: boolean;
|
||||
canMessage: boolean;
|
||||
engagement: {
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
savesCount: number;
|
||||
shareCount: number;
|
||||
viewCount: number;
|
||||
playCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
type FeedCardItem =
|
||||
| {
|
||||
id: string;
|
||||
feedItemType: 'suggested_users';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
items: Array<Record<string, unknown>>;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
feedItemType: 'featured_marketplace';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
listings: Array<Record<string, unknown>>;
|
||||
musicalInstruments: Array<Record<string, unknown>>;
|
||||
instruments: Array<Record<string, unknown>>;
|
||||
repairShops: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FeedService {
|
||||
constructor(
|
||||
private readonly feedRepository: FeedRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly cacheService: AppCacheService,
|
||||
private readonly feedVersionService: FeedVersionService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly likesRepository: LikesRepository,
|
||||
private readonly savesRepository: SavesRepository,
|
||||
private readonly followsService: FollowsService,
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
) {}
|
||||
|
||||
async getMyFeed(currentUserId: string, query: FeedQueryDto) {
|
||||
const cacheEnabled =
|
||||
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
|
||||
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
|
||||
const includeSuggestions = this.shouldIncludeSuggestions(query);
|
||||
const cacheKey = this.buildCacheKey('me', {
|
||||
currentUserId,
|
||||
globalVersion,
|
||||
page: query.page ?? 1,
|
||||
limit: query.limit ?? 20,
|
||||
cursor: query.cursor ?? '',
|
||||
followingOnly: query.followingOnly ?? false,
|
||||
radiusKm: query.radiusKm ?? 30,
|
||||
preferredPostType: query.preferredPostType ?? '',
|
||||
includeSuggestions,
|
||||
suggestionInterval: query.suggestionInterval ?? 4,
|
||||
});
|
||||
if (cacheEnabled) {
|
||||
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const currentUser = await this.usersRepository.findById(currentUserId);
|
||||
if (!currentUser) {
|
||||
throw new NotFoundException('Current user not found');
|
||||
@@ -26,20 +102,10 @@ export class FeedService {
|
||||
const page = query.page ?? 1;
|
||||
const followingOnly = query.followingOnly ?? false;
|
||||
const radiusKm = query.radiusKm ?? 30;
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
|
||||
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
|
||||
const visibleAuthorIds = followingOnly ? [currentUserId, ...followingIds] : null;
|
||||
|
||||
const filter: Record<string, unknown> = {
|
||||
$or: [
|
||||
{ visibility: PostVisibility.PUBLIC },
|
||||
{ authorId: new Types.ObjectId(currentUserId) },
|
||||
],
|
||||
};
|
||||
|
||||
if (visibleAuthorIds) {
|
||||
filter.authorId = { $in: visibleAuthorIds.map((id) => new Types.ObjectId(id)) };
|
||||
}
|
||||
const filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly);
|
||||
|
||||
const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||
|
||||
@@ -64,48 +130,268 @@ export class FeedService {
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
new Date((b.post as any).createdAt ?? 0).getTime() - new Date((a.post as any).createdAt ?? 0).getTime(),
|
||||
new Date((b.post as any).createdAt ?? 0).getTime() -
|
||||
new Date((a.post as any).createdAt ?? 0).getTime(),
|
||||
);
|
||||
|
||||
const total = scored.length;
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
const items = scored.slice(skip, skip + limit).map((entry) => ({
|
||||
...entry.post.toObject(),
|
||||
const pagedPosts = scored.slice(skip, skip + limit).map((entry) => ({
|
||||
...(entry.post.toObject() as unknown as Record<string, unknown>),
|
||||
feedScore: Number(entry.score.toFixed(3)),
|
||||
}));
|
||||
const nextOffset = skip + items.length;
|
||||
const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds);
|
||||
const items = includeSuggestions
|
||||
? await this.mixHomeFeedItems(currentUserId, decoratedPosts, query.suggestionInterval ?? 4)
|
||||
: decoratedPosts;
|
||||
const nextOffset = skip + pagedPosts.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items,
|
||||
const result = buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
offset: skip,
|
||||
currentCursor: query.cursor ?? null,
|
||||
nextCursor,
|
||||
};
|
||||
mode: 'cursor',
|
||||
});
|
||||
|
||||
if (cacheEnabled) {
|
||||
await this.cacheService.set(
|
||||
cacheKey,
|
||||
result,
|
||||
this.configService.get<number>('feedCache.userFeedTtlSeconds', { infer: true }) ?? 15,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getTrending(query: FeedQueryDto) {
|
||||
async getTrending(currentUserId: string, query: FeedQueryDto) {
|
||||
const cacheEnabled =
|
||||
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
|
||||
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
|
||||
const cacheKey = this.buildCacheKey('trending', {
|
||||
currentUserId,
|
||||
globalVersion,
|
||||
page: query.page ?? 1,
|
||||
limit: query.limit ?? 20,
|
||||
cursor: query.cursor ?? '',
|
||||
preferredPostType: query.preferredPostType ?? '',
|
||||
});
|
||||
if (cacheEnabled) {
|
||||
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const page = query.page ?? 1;
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
|
||||
const trendingFilter: Record<string, unknown> = { visibility: PostVisibility.PUBLIC };
|
||||
if (query.preferredPostType) {
|
||||
trendingFilter.postType = query.preferredPostType;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.feedRepository.findTrendingPublicPosts(skip, limit),
|
||||
this.feedRepository.count({ visibility: PostVisibility.PUBLIC }),
|
||||
const [rows, total] = await Promise.all([
|
||||
this.feedRepository.findTrendingPublicPosts(trendingFilter, skip, limit),
|
||||
this.feedRepository.count(trendingFilter),
|
||||
]);
|
||||
const nextOffset = skip + items.length;
|
||||
const decoratedPosts = await this.decoratePostsForViewer(
|
||||
currentUserId,
|
||||
rows.map((item) => item.toObject() as unknown as Record<string, unknown>),
|
||||
followingIds,
|
||||
);
|
||||
const nextOffset = skip + rows.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items,
|
||||
const result = buildPaginatedResponse(decoratedPosts, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
offset: skip,
|
||||
currentCursor: query.cursor ?? null,
|
||||
nextCursor,
|
||||
mode: 'cursor',
|
||||
});
|
||||
|
||||
if (cacheEnabled) {
|
||||
await this.cacheService.set(
|
||||
cacheKey,
|
||||
result,
|
||||
this.configService.get<number>('feedCache.trendingTtlSeconds', { infer: true }) ?? 30,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async decoratePostsForViewer(
|
||||
currentUserId: string,
|
||||
items: Array<Record<string, unknown>>,
|
||||
followingIds: string[],
|
||||
): Promise<FeedPostItem[]> {
|
||||
const postIds = items
|
||||
.map((item) => this.extractEntityId(item._id ?? item.id))
|
||||
.filter(Boolean);
|
||||
const followingSet = new Set(followingIds);
|
||||
const [likedPostIds, savedPostIds] = await Promise.all([
|
||||
this.likesRepository.findLikedPostIds(currentUserId, postIds),
|
||||
this.savesRepository.findSavedPostIds(currentUserId, postIds),
|
||||
]);
|
||||
const likedSet = new Set(likedPostIds);
|
||||
const savedSet = new Set(savedPostIds);
|
||||
|
||||
return items.map((item) => {
|
||||
const postId = this.extractEntityId(item._id ?? item.id);
|
||||
const authorId = this.extractEntityId(item.authorId);
|
||||
const likesCount = Number(item.likesCount ?? 0);
|
||||
const commentsCount = Number(item.commentsCount ?? 0);
|
||||
const savesCount = Number(item.savesCount ?? 0);
|
||||
const shareCount = Number(item.shareCount ?? 0);
|
||||
const viewCount = Number(item.viewCount ?? 0);
|
||||
const playCount = Number(item.playCount ?? 0);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: postId,
|
||||
feedItemType: 'post',
|
||||
likedByMe: likedSet.has(postId),
|
||||
savedByMe: savedSet.has(postId),
|
||||
followingAuthor: !!authorId && followingSet.has(authorId),
|
||||
isOwnPost: authorId === currentUserId,
|
||||
canComment: true,
|
||||
canMessage: !!authorId && authorId !== currentUserId,
|
||||
engagement: {
|
||||
likesCount,
|
||||
commentsCount,
|
||||
savesCount,
|
||||
shareCount,
|
||||
viewCount,
|
||||
playCount,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async mixHomeFeedItems(
|
||||
currentUserId: string,
|
||||
posts: FeedPostItem[],
|
||||
suggestionInterval: number,
|
||||
): Promise<Array<FeedPostItem | FeedCardItem>> {
|
||||
const cards = await this.buildHomeCards(currentUserId);
|
||||
if (!cards.length) {
|
||||
return posts;
|
||||
}
|
||||
|
||||
const result: Array<FeedPostItem | FeedCardItem> = [];
|
||||
let cardIndex = 0;
|
||||
|
||||
for (let index = 0; index < posts.length; index += 1) {
|
||||
result.push(posts[index]);
|
||||
if ((index + 1) % suggestionInterval === 0 && cardIndex < cards.length) {
|
||||
result.push(cards[cardIndex]);
|
||||
cardIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
while (cardIndex < cards.length) {
|
||||
result.push(cards[cardIndex]);
|
||||
cardIndex += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async buildHomeCards(currentUserId: string): Promise<FeedCardItem[]> {
|
||||
const [suggestions, listings, instruments, repairShops] = await Promise.all([
|
||||
this.followsService.getSuggestions(currentUserId, {
|
||||
page: 1,
|
||||
limit: 5,
|
||||
}),
|
||||
this.marketplaceService.getPublicListings({
|
||||
page: 1,
|
||||
limit: 3,
|
||||
isActive: true,
|
||||
} as any),
|
||||
this.marketplaceService.getPublicInstruments({
|
||||
page: 1,
|
||||
limit: 3,
|
||||
isActive: true,
|
||||
} as any),
|
||||
this.marketplaceService.getPublicRepairShops({
|
||||
page: 1,
|
||||
limit: 2,
|
||||
isActive: true,
|
||||
} as any),
|
||||
]);
|
||||
|
||||
const cards: FeedCardItem[] = [];
|
||||
if (Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
||||
cards.push({
|
||||
id: `suggested-users:${currentUserId}`,
|
||||
feedItemType: 'suggested_users',
|
||||
title: 'Suggested creators',
|
||||
subtitle: 'People you may want to follow',
|
||||
items: suggestions.items.map((entry) => ({
|
||||
...entry,
|
||||
following: false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(listings.items?.length ?? 0) > 0 ||
|
||||
(instruments.items?.length ?? 0) > 0 ||
|
||||
(repairShops.items?.length ?? 0) > 0
|
||||
) {
|
||||
cards.push({
|
||||
id: `featured-marketplace:${currentUserId}`,
|
||||
feedItemType: 'featured_marketplace',
|
||||
title: 'Explore marketplace',
|
||||
subtitle: 'Featured listings, musical instruments, and repair shops',
|
||||
listings: (listings.items ?? []) as unknown as Array<Record<string, unknown>>,
|
||||
musicalInstruments: (instruments.items ?? []) as unknown as Array<Record<string, unknown>>,
|
||||
instruments: (instruments.items ?? []) as unknown as Array<Record<string, unknown>>,
|
||||
repairShops: (repairShops.items ?? []) as unknown as Array<Record<string, unknown>>,
|
||||
});
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
private buildVisiblePostsFilter(
|
||||
currentUserId: string,
|
||||
followingIds: string[],
|
||||
followingOnly: boolean,
|
||||
): Record<string, unknown> {
|
||||
const currentUserObjectId = new Types.ObjectId(currentUserId);
|
||||
const followingObjectIds = followingIds.map((id) => new Types.ObjectId(id));
|
||||
|
||||
if (followingOnly) {
|
||||
return {
|
||||
$or: [
|
||||
{ authorId: currentUserObjectId },
|
||||
{
|
||||
authorId: { $in: followingObjectIds },
|
||||
visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
$or: [
|
||||
{ visibility: PostVisibility.PUBLIC },
|
||||
{ authorId: currentUserObjectId },
|
||||
{
|
||||
authorId: { $in: followingObjectIds },
|
||||
visibility: PostVisibility.FOLLOWERS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,13 +399,13 @@ export class FeedService {
|
||||
currentUser: UserDocument;
|
||||
currentUserId: string;
|
||||
followingIds: string[];
|
||||
post: any;
|
||||
post: Record<string, any>;
|
||||
preferredPostType?: PostType;
|
||||
radiusKm: number;
|
||||
}): number {
|
||||
const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input;
|
||||
const author: any = post.authorId;
|
||||
const authorId = typeof author === 'string' ? author : author?._id?.toString?.() ?? '';
|
||||
const author = post.authorId;
|
||||
const authorId = this.extractEntityId(author);
|
||||
const isOwnPost = authorId === currentUserId;
|
||||
const isFollowing = followingIds.includes(authorId);
|
||||
|
||||
@@ -127,10 +413,16 @@ export class FeedService {
|
||||
const ageHours = ageMs / (1000 * 60 * 60);
|
||||
const freshness = Math.max(0, 36 - ageHours);
|
||||
|
||||
const engagement = post.likesCount * 3 + post.commentsCount * 4 + post.savesCount * 5;
|
||||
const engagement =
|
||||
Number(post.likesCount ?? 0) * 3 +
|
||||
Number(post.commentsCount ?? 0) * 4 +
|
||||
Number(post.savesCount ?? 0) * 5 +
|
||||
Number(post.shareCount ?? 0) * 6 +
|
||||
Number(post.viewCount ?? 0) * 0.15 +
|
||||
Number(post.playCount ?? 0) * 0.25;
|
||||
const hashtagMatches = this.intersectionCount(
|
||||
this.buildPreferenceTokens(currentUser),
|
||||
(post.hashtags ?? []).map((x: string) => x.toLowerCase()),
|
||||
(post.hashtags ?? []).map((value: string) => value.toLowerCase()),
|
||||
);
|
||||
|
||||
const distanceKm = this.computeDistanceKm(
|
||||
@@ -209,4 +501,45 @@ export class FeedService {
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return earthKm * c;
|
||||
}
|
||||
|
||||
private buildCacheKey(scope: string, input: Record<string, unknown>): string {
|
||||
return `feed:${scope}:${JSON.stringify(input)}`;
|
||||
}
|
||||
|
||||
private shouldIncludeSuggestions(query: FeedQueryDto): boolean {
|
||||
return (
|
||||
query.includeSuggestions === true &&
|
||||
!(query.cursor ?? '').trim() &&
|
||||
(query.page ?? 1) === 1
|
||||
);
|
||||
}
|
||||
|
||||
private extractEntityId(value: unknown): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof Types.ObjectId) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const candidate = value as { _id?: unknown; id?: unknown };
|
||||
if (candidate._id instanceof Types.ObjectId) {
|
||||
return candidate._id.toString();
|
||||
}
|
||||
if (typeof candidate._id === 'string') {
|
||||
return candidate._id;
|
||||
}
|
||||
if (typeof candidate.id === 'string') {
|
||||
return candidate.id;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,14 @@ export class FollowsController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('followers/:userId')
|
||||
async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getFollowers(userId, query.page, query.limit);
|
||||
return this.followsService.getFollowers(userId, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('following/:userId')
|
||||
async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getFollowing(userId, query.page, query.limit);
|
||||
return this.followsService.getFollowing(userId, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@@ -47,6 +47,6 @@ export class FollowsController {
|
||||
@Get('suggestions')
|
||||
@Throttle(60, 60_000)
|
||||
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getSuggestions(user.sub, query.page, query.limit);
|
||||
return this.followsService.getSuggestions(user.sub, query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,17 @@ export class FollowsRepository {
|
||||
await this.followModel.findByIdAndDelete(id, { session }).exec();
|
||||
}
|
||||
|
||||
async findMany(filter: FilterQuery<FollowDocument>, skip: number, limit: number): Promise<FollowDocument[]> {
|
||||
async findMany(
|
||||
filter: FilterQuery<FollowDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<FollowDocument[]> {
|
||||
return this.followModel
|
||||
.find(filter)
|
||||
.populate({ path: 'followerId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.populate({ path: 'followingId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ createdAt: -1 })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('FollowsService', () => {
|
||||
followsRepository as any,
|
||||
usersRepository as any,
|
||||
outboxService as any,
|
||||
{ bumpGlobalVersion: jest.fn().mockResolvedValue(1) } as any,
|
||||
);
|
||||
|
||||
await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||
import { OutboxService } from '../outbox/outbox.service';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { UserDocument } from '../users/schemas/user.schema';
|
||||
@@ -14,6 +18,7 @@ export class FollowsService {
|
||||
private readonly followsRepository: FollowsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly outboxService: OutboxService,
|
||||
private readonly feedVersionService: FeedVersionService,
|
||||
) {}
|
||||
|
||||
async toggleFollow(currentUserId: string, dto: ToggleFollowDto) {
|
||||
@@ -37,11 +42,13 @@ export class FollowsService {
|
||||
if (existing) {
|
||||
await this.followsRepository.deleteById(existing.id);
|
||||
await this.syncFollowCounts(currentUserId, targetUserId);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
return { following: false };
|
||||
}
|
||||
|
||||
const follow = await this.followsRepository.create(currentUserId, targetUserId);
|
||||
await this.syncFollowCounts(currentUserId, targetUserId);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
|
||||
try {
|
||||
await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id);
|
||||
@@ -56,36 +63,40 @@ export class FollowsService {
|
||||
return { following: true };
|
||||
}
|
||||
|
||||
async getFollowers(userId: string, page = 1, limit = 20) {
|
||||
async getFollowers(userId: string, query: PaginationQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
const [items, total] = await Promise.all([
|
||||
this.followsRepository.findMany({ followingId: userId }, skip, limit),
|
||||
this.followsRepository.findMany({ followingId: userId }, skip, limit, sort),
|
||||
this.followsRepository.count({ followingId: userId }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
offset: skip,
|
||||
});
|
||||
}
|
||||
|
||||
async getFollowing(userId: string, page = 1, limit = 20) {
|
||||
async getFollowing(userId: string, query: PaginationQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
const [items, total] = await Promise.all([
|
||||
this.followsRepository.findMany({ followerId: userId }, skip, limit),
|
||||
this.followsRepository.findMany({ followerId: userId }, skip, limit, sort),
|
||||
this.followsRepository.count({ followerId: userId }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
offset: skip,
|
||||
});
|
||||
}
|
||||
|
||||
async getFollowStatus(currentUserId: string, targetUserId: string) {
|
||||
@@ -104,7 +115,9 @@ export class FollowsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getSuggestions(currentUserId: string, page = 1, limit = 20) {
|
||||
async getSuggestions(currentUserId: string, query: PaginationQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const currentUser = await this.usersRepository.findById(currentUserId);
|
||||
if (!currentUser) {
|
||||
throw new NotFoundException('Current user not found');
|
||||
@@ -128,6 +141,10 @@ export class FollowsService {
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score || b.user.followersCount - a.user.followersCount);
|
||||
|
||||
if (query.sortOrder === 'asc') {
|
||||
ranked.reverse();
|
||||
}
|
||||
|
||||
const total = ranked.length;
|
||||
const skip = (page - 1) * limit;
|
||||
const items = ranked.slice(skip, skip + limit).map((entry) => ({
|
||||
@@ -136,13 +153,12 @@ export class FollowsService {
|
||||
reasons: this.buildSuggestionReasons(currentUser, entry.user),
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
offset: skip,
|
||||
});
|
||||
}
|
||||
|
||||
private calculateSuggestionScore(currentUser: UserDocument, candidate: UserDocument): number {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { CommentsModule } from '../comments/comments.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PostsModule } from '../posts/posts.module';
|
||||
import { Like, LikeSchema } from './schemas/like.schema';
|
||||
import { LikesController } from './likes.controller';
|
||||
@@ -12,9 +13,10 @@ import { LikesService } from './likes.service';
|
||||
MongooseModule.forFeature([{ name: Like.name, schema: LikeSchema }]),
|
||||
PostsModule,
|
||||
CommentsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [LikesController],
|
||||
providers: [LikesService, LikesRepository],
|
||||
exports: [LikesService],
|
||||
exports: [LikesService, LikesRepository],
|
||||
})
|
||||
export class LikesModule {}
|
||||
|
||||
@@ -25,6 +25,24 @@ export class LikesRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findLikedPostIds(userId: string, postIds: string[]): Promise<string[]> {
|
||||
if (!postIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.likeModel
|
||||
.find({
|
||||
userId: new Types.ObjectId(userId),
|
||||
targetType: 'post',
|
||||
targetId: { $in: postIds.map((id) => new Types.ObjectId(id)) },
|
||||
})
|
||||
.select({ targetId: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return rows.map((row) => row.targetId.toString());
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.likeModel.findByIdAndDelete(id).exec();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ describe('LikesService', () => {
|
||||
likesRepository as any,
|
||||
postsRepository as any,
|
||||
commentsRepository as any,
|
||||
{ bumpGlobalVersion: jest.fn() } as any,
|
||||
{ createLikeNotification: jest.fn() } as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { CommentsRepository } from '../comments/comments.repository';
|
||||
import { PostsRepository } from '../posts/posts.repository';
|
||||
import { LikesRepository } from './likes.repository';
|
||||
@@ -6,10 +9,14 @@ import { ToggleLikeDto } from './dto/toggle-like.dto';
|
||||
|
||||
@Injectable()
|
||||
export class LikesService {
|
||||
private readonly logger = new Logger(LikesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly likesRepository: LikesRepository,
|
||||
private readonly postsRepository: PostsRepository,
|
||||
private readonly commentsRepository: CommentsRepository,
|
||||
private readonly feedVersionService: FeedVersionService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async toggle(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||
@@ -19,6 +26,7 @@ export class LikesService {
|
||||
|
||||
async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||
await this.assertTargetExists(dto);
|
||||
const notificationContext = await this.resolveNotificationContext(dto);
|
||||
|
||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
||||
if (existing) {
|
||||
@@ -29,6 +37,26 @@ export class LikesService {
|
||||
if (dto.targetType === 'post') {
|
||||
await this.postsRepository.incrementLikesCount(dto.targetId, 1);
|
||||
}
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
if (notificationContext.recipientId && notificationContext.recipientId !== userId) {
|
||||
try {
|
||||
await this.notificationsService.createLikeNotification(
|
||||
userId,
|
||||
notificationContext.recipientId,
|
||||
dto.targetId,
|
||||
{
|
||||
resourceType: dto.targetType,
|
||||
previewText: notificationContext.previewText,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Like notification failed for actor=${userId} recipient=${notificationContext.recipientId}: ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
@@ -45,6 +73,7 @@ export class LikesService {
|
||||
if (dto.targetType === 'post') {
|
||||
await this.postsRepository.incrementLikesCount(dto.targetId, -1);
|
||||
}
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
|
||||
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
@@ -75,4 +104,51 @@ export class LikesService {
|
||||
const comment = await this.commentsRepository.findById(dto.targetId);
|
||||
return !!comment;
|
||||
}
|
||||
|
||||
private async resolveNotificationContext(
|
||||
dto: ToggleLikeDto,
|
||||
): Promise<{ recipientId: string; previewText: string }> {
|
||||
if (dto.targetType === 'post') {
|
||||
const post = await this.postsRepository.findById(dto.targetId);
|
||||
return {
|
||||
recipientId: this.extractEntityId(post?.authorId),
|
||||
previewText: (post?.content ?? '').slice(0, 140),
|
||||
};
|
||||
}
|
||||
|
||||
const comment = await this.commentsRepository.findById(dto.targetId);
|
||||
return {
|
||||
recipientId: comment?.authorId?.toString?.() ?? '',
|
||||
previewText: (comment?.content ?? '').slice(0, 140),
|
||||
};
|
||||
}
|
||||
|
||||
private extractEntityId(value: unknown): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof Types.ObjectId) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const candidate = value as { _id?: unknown; id?: unknown };
|
||||
if (candidate._id instanceof Types.ObjectId) {
|
||||
return candidate._id.toString();
|
||||
}
|
||||
if (typeof candidate._id === 'string') {
|
||||
return candidate._id;
|
||||
}
|
||||
if (typeof candidate.id === 'string') {
|
||||
return candidate.id;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -10,6 +11,10 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum';
|
||||
import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum';
|
||||
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export class CreateInstrumentDto {
|
||||
@IsString()
|
||||
@@ -38,13 +43,27 @@ export class CreateInstrumentDto {
|
||||
quantity!: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(5)
|
||||
@IsString({ each: true })
|
||||
imageUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(MarketplaceListingCondition)
|
||||
condition?: MarketplaceListingCondition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
instrumentType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(MarketplaceListingCategory)
|
||||
listingCategory?: MarketplaceListingCategory;
|
||||
}
|
||||
|
||||
74
src/modules/marketplace/dto/create-repair-shop.dto.ts
Normal file
74
src/modules/marketplace/dto/create-repair-shop.dto.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export class CreateRepairShopDto {
|
||||
@IsString()
|
||||
@Length(2, 120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 2000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
services?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 40)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 40)
|
||||
whatsapp?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(8)
|
||||
@IsUrl({ require_tld: false }, { each: true })
|
||||
imageUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 160)
|
||||
location?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -1,29 +1,60 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum';
|
||||
import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum';
|
||||
|
||||
export const INSTRUMENT_SORT_FIELDS = ['createdAt', 'updatedAt', 'price', 'title'] as const;
|
||||
export type InstrumentSortField = (typeof INSTRUMENT_SORT_FIELDS)[number];
|
||||
|
||||
export class InstrumentQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search by title or description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: MarketplaceListingCondition })
|
||||
@IsOptional()
|
||||
@IsEnum(MarketplaceListingCondition)
|
||||
condition?: MarketplaceListingCondition;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by instrument type such as oud, piano, violin' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
instrumentType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: INSTRUMENT_SORT_FIELDS, default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsEnum(INSTRUMENT_SORT_FIELDS)
|
||||
sortBy?: InstrumentSortField;
|
||||
|
||||
@ApiPropertyOptional({ enum: MarketplaceListingCategory })
|
||||
@IsOptional()
|
||||
@IsEnum(MarketplaceListingCategory)
|
||||
listingCategory?: MarketplaceListingCategory;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
|
||||
36
src/modules/marketplace/dto/marketplace-home-query.dto.ts
Normal file
36
src/modules/marketplace/dto/marketplace-home-query.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export class MarketplaceHomeQueryDto {
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 20, default: 6 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
listingsLimit?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 20, default: 6 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
instrumentsLimit?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 20, default: 4 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
repairShopsLimit?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
onlyActive?: boolean;
|
||||
}
|
||||
33
src/modules/marketplace/dto/repair-shop-query.dto.ts
Normal file
33
src/modules/marketplace/dto/repair-shop-query.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export const REPAIR_SHOP_SORT_FIELDS = ['createdAt', 'updatedAt', 'name'] as const;
|
||||
export type RepairShopSortField = (typeof REPAIR_SHOP_SORT_FIELDS)[number];
|
||||
|
||||
export class RepairShopQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search by name, description, services, or location' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: REPAIR_SHOP_SORT_FIELDS, default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsEnum(REPAIR_SHOP_SORT_FIELDS)
|
||||
sortBy?: RepairShopSortField;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number;
|
||||
}
|
||||
14
src/modules/marketplace/dto/update-marketplace-status.dto.ts
Normal file
14
src/modules/marketplace/dto/update-marketplace-status.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateMarketplaceStatusDto {
|
||||
@ApiProperty({ example: false })
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Listing disabled pending verification' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1200)
|
||||
reason?: string;
|
||||
}
|
||||
4
src/modules/marketplace/dto/update-repair-shop.dto.ts
Normal file
4
src/modules/marketplace/dto/update-repair-shop.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateRepairShopDto } from './create-repair-shop.dto';
|
||||
|
||||
export class UpdateRepairShopDto extends PartialType(CreateRepairShopDto) {}
|
||||
51
src/modules/marketplace/dto/update-shop-profile.dto.ts
Normal file
51
src/modules/marketplace/dto/update-shop-profile.dto.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||
|
||||
export class UpdateShopProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(2, 120)
|
||||
shopName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 2000)
|
||||
shopDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@ArrayMaxSize(8)
|
||||
@IsUrl({ require_tld: false }, { each: true })
|
||||
shopImageUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 160)
|
||||
shopLocation?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
shopLatitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
shopLongitude?: number;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum MarketplaceListingCategory {
|
||||
MUSICAL_INSTRUMENT = 'musical_instrument',
|
||||
ACCESSORY = 'accessory',
|
||||
AUDIO_GEAR = 'audio_gear',
|
||||
SHEET_MUSIC = 'sheet_music',
|
||||
OTHER = 'other',
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum MarketplaceListingCondition {
|
||||
NEW = 'new',
|
||||
LIKE_NEW = 'like_new',
|
||||
USED = 'used',
|
||||
REFURBISHED = 'refurbished',
|
||||
}
|
||||
@@ -1,52 +1,461 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||
import { UserRole } from '../../common/enums/user-role.enum';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { CreateInstrumentDto } from './dto/create-instrument.dto';
|
||||
import { CreateRepairShopDto } from './dto/create-repair-shop.dto';
|
||||
import { InstrumentQueryDto } from './dto/instrument-query.dto';
|
||||
import { MarketplaceHomeQueryDto } from './dto/marketplace-home-query.dto';
|
||||
import { RepairShopQueryDto } from './dto/repair-shop-query.dto';
|
||||
import { UpdateShopProfileDto } from './dto/update-shop-profile.dto';
|
||||
import { UpdateInstrumentDto } from './dto/update-instrument.dto';
|
||||
import { UpdateMarketplaceStatusDto } from './dto/update-marketplace-status.dto';
|
||||
import { UpdateRepairShopDto } from './dto/update-repair-shop.dto';
|
||||
import { MarketplaceService } from './marketplace.service';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
|
||||
@ApiTags('Marketplace')
|
||||
@Controller('marketplace')
|
||||
export class MarketplaceController {
|
||||
constructor(private readonly marketplaceService: MarketplaceService) {}
|
||||
|
||||
@Get('home')
|
||||
async getMarketplaceHome(@Query() query: MarketplaceHomeQueryDto) {
|
||||
return this.marketplaceService.getHome(query);
|
||||
}
|
||||
|
||||
@Get('listings')
|
||||
async listPublicListings(@Query() query: InstrumentQueryDto) {
|
||||
return this.marketplaceService.getPublicListings(query);
|
||||
}
|
||||
|
||||
@Get('listings/:id')
|
||||
async findListing(@Param('id') listingId: string) {
|
||||
return this.marketplaceService.findListingById(listingId);
|
||||
}
|
||||
|
||||
@Get('instruments')
|
||||
async listPublic(@Query() query: InstrumentQueryDto) {
|
||||
return this.marketplaceService.getPublic(query);
|
||||
return this.marketplaceService.getPublicInstruments(query);
|
||||
}
|
||||
|
||||
@Get('instruments/:id')
|
||||
async findOne(@Param('id') instrumentId: string) {
|
||||
return this.marketplaceService.findById(instrumentId);
|
||||
return this.marketplaceService.findInstrumentById(instrumentId);
|
||||
}
|
||||
|
||||
@Get('repair-shops')
|
||||
async listPublicRepairShops(@Query() query: RepairShopQueryDto) {
|
||||
return this.marketplaceService.getPublicRepairShops(query);
|
||||
}
|
||||
|
||||
@Get('repair-shops/:id')
|
||||
async findRepairShop(@Param('id') repairShopId: string) {
|
||||
return this.marketplaceService.findRepairShopById(repairShopId);
|
||||
}
|
||||
|
||||
@Get('shops/:adminId')
|
||||
async getShopByAdminId(@Param('adminId') adminId: string) {
|
||||
return this.marketplaceService.getShopProfileByAdminId(adminId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||
@Get('superadmin/listings')
|
||||
async listAllListingsForSuperAdmin(@Query() query: InstrumentQueryDto) {
|
||||
return this.marketplaceService.getListingsForSuperAdmin(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||
@Patch('superadmin/listings/:id/status')
|
||||
async updateListingStatusBySuperAdmin(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') listingId: string,
|
||||
@Body() dto: UpdateMarketplaceStatusDto,
|
||||
) {
|
||||
return this.marketplaceService.updateListingStatusBySuperAdmin(
|
||||
user.email ?? user.sub,
|
||||
listingId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||
@Delete('superadmin/listings/:id')
|
||||
async deleteListingBySuperAdmin(@CurrentUser() user: JwtPayload, @Param('id') listingId: string) {
|
||||
await this.marketplaceService.removeListingBySuperAdmin(user.email ?? user.sub, listingId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||
@Get('superadmin/repair-shops')
|
||||
async listAllRepairShopsForSuperAdmin(@Query() query: RepairShopQueryDto) {
|
||||
return this.marketplaceService.getRepairShopsForSuperAdmin(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||
@Patch('superadmin/repair-shops/:id/status')
|
||||
async updateRepairShopStatusBySuperAdmin(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') repairShopId: string,
|
||||
@Body() dto: UpdateMarketplaceStatusDto,
|
||||
) {
|
||||
return this.marketplaceService.updateRepairShopStatusBySuperAdmin(
|
||||
user.email ?? user.sub,
|
||||
repairShopId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||
@Delete('superadmin/repair-shops/:id')
|
||||
async deleteRepairShopBySuperAdmin(@CurrentUser() user: JwtPayload, @Param('id') repairShopId: string) {
|
||||
await this.marketplaceService.removeRepairShopBySuperAdmin(user.email ?? user.sub, repairShopId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', example: 'Professional Oud' },
|
||||
description: { type: 'string', example: 'Well-maintained oud for studio work' },
|
||||
price: { type: 'number', example: 3500 },
|
||||
currency: { type: 'string', example: 'SAR' },
|
||||
quantity: { type: 'number', example: 1 },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
isActive: { type: 'boolean', example: true },
|
||||
condition: { type: 'string', example: 'used' },
|
||||
instrumentType: { type: 'string', example: 'Oud' },
|
||||
listingCategory: { type: 'string', example: 'musical_instrument' },
|
||||
},
|
||||
required: ['title', 'price', 'quantity'],
|
||||
},
|
||||
})
|
||||
@Post('superadmin/admins/:adminId/listings')
|
||||
@Throttle(40, 60_000)
|
||||
async createListingBySuperAdmin(
|
||||
@Param('adminId') adminId: string,
|
||||
@Body() dto: CreateInstrumentDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.createListingBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', example: 'Concert Guitar' },
|
||||
description: { type: 'string', example: 'Acoustic guitar in excellent condition' },
|
||||
price: { type: 'number', example: 2100 },
|
||||
currency: { type: 'string', example: 'SAR' },
|
||||
quantity: { type: 'number', example: 1 },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
isActive: { type: 'boolean', example: true },
|
||||
condition: { type: 'string', example: 'used' },
|
||||
instrumentType: { type: 'string', example: 'Guitar' },
|
||||
},
|
||||
required: ['title', 'price', 'quantity'],
|
||||
},
|
||||
})
|
||||
@Post('superadmin/admins/:adminId/instruments')
|
||||
@Throttle(40, 60_000)
|
||||
async createInstrumentBySuperAdmin(
|
||||
@Param('adminId') adminId: string,
|
||||
@Body() dto: CreateInstrumentDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.createInstrumentBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'Fix Strings Workshop' },
|
||||
description: { type: 'string', example: 'Repair shop for oud and violin' },
|
||||
services: { type: 'array', items: { type: 'string' } },
|
||||
phone: { type: 'string', example: '+966500000000' },
|
||||
whatsapp: { type: 'string', example: '+966500000000' },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
location: { type: 'string', example: 'Riyadh' },
|
||||
latitude: { type: 'number', example: 24.7136 },
|
||||
longitude: { type: 'number', example: 46.6753 },
|
||||
isActive: { type: 'boolean', example: true },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
})
|
||||
@Post('superadmin/admins/:adminId/repair-shops')
|
||||
@Throttle(30, 60_000)
|
||||
async createRepairShopBySuperAdmin(
|
||||
@Param('adminId') adminId: string,
|
||||
@Body() dto: CreateRepairShopDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.createRepairShopBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
shopName: { type: 'string', example: 'Awtarna Store' },
|
||||
shopDescription: { type: 'string', example: 'Trusted marketplace shop profile' },
|
||||
shopImageUrls: { type: 'array', items: { type: 'string' } },
|
||||
shopImageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
shopLocation: { type: 'string', example: 'Riyadh' },
|
||||
shopLatitude: { type: 'number', example: 24.7136 },
|
||||
shopLongitude: { type: 'number', example: 46.6753 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@Patch('superadmin/admins/:adminId/shop-profile')
|
||||
@Throttle(30, 60_000)
|
||||
async updateShopProfileBySuperAdmin(
|
||||
@Param('adminId') adminId: string,
|
||||
@Body() dto: UpdateShopProfileDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
shopImageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.updateShopProfileBySuperAdmin(
|
||||
adminId,
|
||||
dto,
|
||||
files?.shopImageFiles ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', example: 'Professional Oud' },
|
||||
description: { type: 'string', example: 'Well-maintained oud for studio work' },
|
||||
price: { type: 'number', example: 3500 },
|
||||
currency: { type: 'string', example: 'SAR' },
|
||||
quantity: { type: 'number', example: 1 },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
isActive: { type: 'boolean', example: true },
|
||||
condition: { type: 'string', example: 'used' },
|
||||
instrumentType: { type: 'string', example: 'Oud' },
|
||||
listingCategory: { type: 'string', example: 'musical_instrument' },
|
||||
},
|
||||
required: ['title', 'price', 'quantity'],
|
||||
},
|
||||
})
|
||||
@Post('admin/listings')
|
||||
@Throttle(40, 60_000)
|
||||
async createListingByAdmin(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: CreateInstrumentDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.createListingByAdmin(user.sub, dto, files?.imageFiles ?? []);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', example: 'Updated listing title' },
|
||||
description: { type: 'string', example: 'Updated description' },
|
||||
price: { type: 'number', example: 3200 },
|
||||
currency: { type: 'string', example: 'SAR' },
|
||||
quantity: { type: 'number', example: 1 },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
isActive: { type: 'boolean', example: true },
|
||||
instrumentType: { type: 'string', example: 'Oud' },
|
||||
listingCategory: { type: 'string', example: 'musical_instrument' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@Patch('admin/listings/:id')
|
||||
@Throttle(60, 60_000)
|
||||
async updateListingByAdmin(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') listingId: string,
|
||||
@Body() dto: UpdateInstrumentDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.updateListingByAdmin(user.sub, listingId, dto, files?.imageFiles ?? []);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Delete('admin/listings/:id')
|
||||
@Throttle(40, 60_000)
|
||||
async removeListingByAdmin(@CurrentUser() user: JwtPayload, @Param('id') listingId: string) {
|
||||
await this.marketplaceService.removeListingByAdmin(user.sub, listingId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('admin/listings/me')
|
||||
async myListings(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) {
|
||||
return this.marketplaceService.getMyListings(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', example: 'Concert Guitar' },
|
||||
description: { type: 'string', example: 'Acoustic guitar in excellent condition' },
|
||||
price: { type: 'number', example: 2100 },
|
||||
currency: { type: 'string', example: 'SAR' },
|
||||
quantity: { type: 'number', example: 1 },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
isActive: { type: 'boolean', example: true },
|
||||
condition: { type: 'string', example: 'used' },
|
||||
instrumentType: { type: 'string', example: 'Guitar' },
|
||||
},
|
||||
required: ['title', 'price', 'quantity'],
|
||||
},
|
||||
})
|
||||
@Post('admin/instruments')
|
||||
@Throttle(40, 60_000)
|
||||
async createByAdmin(@CurrentUser() user: JwtPayload, @Body() dto: CreateInstrumentDto) {
|
||||
return this.marketplaceService.createByAdmin(user.sub, dto);
|
||||
async createByAdmin(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: CreateInstrumentDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.createInstrumentByAdmin(user.sub, dto, files?.imageFiles ?? []);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', example: 'Updated instrument title' },
|
||||
description: { type: 'string', example: 'Updated instrument description' },
|
||||
price: { type: 'number', example: 2400 },
|
||||
quantity: { type: 'number', example: 1 },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
condition: { type: 'string', example: 'used' },
|
||||
instrumentType: { type: 'string', example: 'Violin' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@Patch('admin/instruments/:id')
|
||||
@Throttle(60, 60_000)
|
||||
async updateByAdmin(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') instrumentId: string,
|
||||
@Body() dto: UpdateInstrumentDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.updateByAdmin(user.sub, instrumentId, dto);
|
||||
return this.marketplaceService.updateInstrumentByAdmin(
|
||||
user.sub,
|
||||
instrumentId,
|
||||
dto,
|
||||
files?.imageFiles ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@@ -55,7 +464,7 @@ export class MarketplaceController {
|
||||
@Delete('admin/instruments/:id')
|
||||
@Throttle(40, 60_000)
|
||||
async removeByAdmin(@CurrentUser() user: JwtPayload, @Param('id') instrumentId: string) {
|
||||
await this.marketplaceService.removeByAdmin(user.sub, instrumentId);
|
||||
await this.marketplaceService.removeInstrumentByAdmin(user.sub, instrumentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -64,6 +473,147 @@ export class MarketplaceController {
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('admin/instruments/me')
|
||||
async myInstruments(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) {
|
||||
return this.marketplaceService.getMine(user.sub, query);
|
||||
return this.marketplaceService.getMyInstruments(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'Fix Strings Workshop' },
|
||||
description: { type: 'string', example: 'Repair shop for oud and violin' },
|
||||
services: { type: 'array', items: { type: 'string' } },
|
||||
phone: { type: 'string', example: '+966500000000' },
|
||||
whatsapp: { type: 'string', example: '+966500000000' },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
location: { type: 'string', example: 'Riyadh' },
|
||||
latitude: { type: 'number', example: 24.7136 },
|
||||
longitude: { type: 'number', example: 46.6753 },
|
||||
isActive: { type: 'boolean', example: true },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
})
|
||||
@Post('admin/repair-shops')
|
||||
@Throttle(30, 60_000)
|
||||
async createRepairShop(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: CreateRepairShopDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.createRepairShop(user.sub, dto, files?.imageFiles ?? []);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'Updated shop name' },
|
||||
description: { type: 'string', example: 'Updated repair shop description' },
|
||||
services: { type: 'array', items: { type: 'string' } },
|
||||
phone: { type: 'string', example: '+966500000000' },
|
||||
whatsapp: { type: 'string', example: '+966500000000' },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
location: { type: 'string', example: 'Jeddah' },
|
||||
latitude: { type: 'number', example: 21.5433 },
|
||||
longitude: { type: 'number', example: 39.1728 },
|
||||
isActive: { type: 'boolean', example: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
@Patch('admin/repair-shops/:id')
|
||||
@Throttle(40, 60_000)
|
||||
async updateRepairShop(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') repairShopId: string,
|
||||
@Body() dto: UpdateRepairShopDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.updateRepairShop(
|
||||
user.sub,
|
||||
repairShopId,
|
||||
dto,
|
||||
files?.imageFiles ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Delete('admin/repair-shops/:id')
|
||||
@Throttle(30, 60_000)
|
||||
async deleteRepairShop(@CurrentUser() user: JwtPayload, @Param('id') repairShopId: string) {
|
||||
await this.marketplaceService.removeRepairShop(user.sub, repairShopId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('admin/repair-shops/me')
|
||||
async myRepairShops(@CurrentUser() user: JwtPayload, @Query() query: RepairShopQueryDto) {
|
||||
return this.marketplaceService.getMyRepairShops(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('admin/shop-profile/me')
|
||||
async myShopProfile(@CurrentUser() user: JwtPayload) {
|
||||
return this.marketplaceService.getMyShopProfile(user.sub);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }]))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
shopName: { type: 'string', example: 'Awtarna Store' },
|
||||
shopDescription: { type: 'string', example: 'Trusted marketplace shop profile' },
|
||||
shopImageUrls: { type: 'array', items: { type: 'string' } },
|
||||
shopImageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
|
||||
shopLocation: { type: 'string', example: 'Riyadh' },
|
||||
shopLatitude: { type: 'number', example: 24.7136 },
|
||||
shopLongitude: { type: 'number', example: 46.6753 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@Patch('admin/shop-profile')
|
||||
@Throttle(30, 60_000)
|
||||
async updateShopProfile(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdateShopProfileDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
shopImageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
|
||||
},
|
||||
) {
|
||||
return this.marketplaceService.updateMyShopProfile(
|
||||
user.sub,
|
||||
dto,
|
||||
files?.shopImageFiles ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { SuperAdminCase, SuperAdminCaseSchema } from '../superadmin/schemas/superadmin-case.schema';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { MarketplaceController } from './marketplace.controller';
|
||||
import { MarketplaceRepository } from './marketplace.repository';
|
||||
import { MarketplaceService } from './marketplace.service';
|
||||
import { Instrument, InstrumentSchema } from './schemas/instrument.schema';
|
||||
import { RepairShop, RepairShopSchema } from './schemas/repair-shop.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuditModule,
|
||||
UsersModule,
|
||||
MongooseModule.forFeature([{ name: Instrument.name, schema: InstrumentSchema }]),
|
||||
MongooseModule.forFeature([
|
||||
{ name: Instrument.name, schema: InstrumentSchema },
|
||||
{ name: RepairShop.name, schema: RepairShopSchema },
|
||||
{ name: SuperAdminCase.name, schema: SuperAdminCaseSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [MarketplaceController],
|
||||
providers: [MarketplaceService, MarketplaceRepository],
|
||||
|
||||
@@ -2,12 +2,15 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
|
||||
import { Instrument, InstrumentDocument } from './schemas/instrument.schema';
|
||||
import { RepairShop, RepairShopDocument } from './schemas/repair-shop.schema';
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceRepository {
|
||||
constructor(
|
||||
@InjectModel(Instrument.name)
|
||||
private readonly instrumentModel: Model<InstrumentDocument>,
|
||||
@InjectModel(RepairShop.name)
|
||||
private readonly repairShopModel: Model<RepairShopDocument>,
|
||||
) {}
|
||||
|
||||
async create(ownerAdminId: string, payload: Partial<Instrument>): Promise<InstrumentDocument> {
|
||||
@@ -23,7 +26,7 @@ export class MarketplaceRepository {
|
||||
}
|
||||
return this.instrumentModel
|
||||
.findById(instrumentId)
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
@@ -37,7 +40,7 @@ export class MarketplaceRepository {
|
||||
|
||||
return this.instrumentModel
|
||||
.findByIdAndUpdate(instrumentId, payload, { new: true })
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
@@ -52,11 +55,12 @@ export class MarketplaceRepository {
|
||||
filter: FilterQuery<InstrumentDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<InstrumentDocument[]> {
|
||||
return this.instrumentModel
|
||||
.find(filter)
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
|
||||
.sort({ createdAt: -1 })
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
@@ -66,6 +70,7 @@ export class MarketplaceRepository {
|
||||
filter: FilterQuery<InstrumentDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return this.instrumentModel
|
||||
.aggregate([
|
||||
@@ -80,7 +85,7 @@ export class MarketplaceRepository {
|
||||
},
|
||||
{ $unwind: '$ownerAdmin' },
|
||||
{ $match: { 'ownerAdmin.isDisabled': false } },
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $sort: sort },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
{
|
||||
@@ -93,6 +98,9 @@ export class MarketplaceRepository {
|
||||
quantity: 1,
|
||||
imageUrls: 1,
|
||||
isActive: 1,
|
||||
condition: 1,
|
||||
instrumentType: 1,
|
||||
listingCategory: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
ownerAdminId: {
|
||||
@@ -101,6 +109,7 @@ export class MarketplaceRepository {
|
||||
username: '$ownerAdmin.username',
|
||||
email: '$ownerAdmin.email',
|
||||
avatar: '$ownerAdmin.avatar',
|
||||
shopName: '$ownerAdmin.shopName',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -132,4 +141,147 @@ export class MarketplaceRepository {
|
||||
async count(filter: FilterQuery<InstrumentDocument>): Promise<number> {
|
||||
return this.instrumentModel.countDocuments(filter).exec();
|
||||
}
|
||||
|
||||
async createRepairShop(
|
||||
ownerAdminId: string,
|
||||
payload: Partial<RepairShop>,
|
||||
): Promise<RepairShopDocument> {
|
||||
return this.repairShopModel.create({
|
||||
...payload,
|
||||
ownerAdminId: new Types.ObjectId(ownerAdminId),
|
||||
});
|
||||
}
|
||||
|
||||
async findRepairShopByOwnerAdminId(ownerAdminId: string): Promise<RepairShopDocument | null> {
|
||||
if (!Types.ObjectId.isValid(ownerAdminId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.repairShopModel
|
||||
.findOne({ ownerAdminId: new Types.ObjectId(ownerAdminId) })
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findRepairShopById(repairShopId: string): Promise<RepairShopDocument | null> {
|
||||
if (!Types.ObjectId.isValid(repairShopId)) {
|
||||
return null;
|
||||
}
|
||||
return this.repairShopModel
|
||||
.findById(repairShopId)
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateRepairShopById(
|
||||
repairShopId: string,
|
||||
payload: UpdateQuery<RepairShopDocument>,
|
||||
): Promise<RepairShopDocument | null> {
|
||||
if (!Types.ObjectId.isValid(repairShopId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.repairShopModel
|
||||
.findByIdAndUpdate(repairShopId, payload, { new: true })
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async deleteRepairShopById(repairShopId: string): Promise<RepairShopDocument | null> {
|
||||
if (!Types.ObjectId.isValid(repairShopId)) {
|
||||
return null;
|
||||
}
|
||||
return this.repairShopModel.findByIdAndDelete(repairShopId).exec();
|
||||
}
|
||||
|
||||
async findManyRepairShops(
|
||||
filter: FilterQuery<RepairShopDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<RepairShopDocument[]> {
|
||||
return this.repairShopModel
|
||||
.find(filter)
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findManyRepairShopsPublic(
|
||||
filter: FilterQuery<RepairShopDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return this.repairShopModel
|
||||
.aggregate([
|
||||
{ $match: filter },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'ownerAdminId',
|
||||
foreignField: '_id',
|
||||
as: 'ownerAdmin',
|
||||
},
|
||||
},
|
||||
{ $unwind: '$ownerAdmin' },
|
||||
{ $match: { 'ownerAdmin.isDisabled': false } },
|
||||
{ $sort: sort },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
description: 1,
|
||||
services: 1,
|
||||
phone: 1,
|
||||
whatsapp: 1,
|
||||
imageUrls: 1,
|
||||
location: 1,
|
||||
latitude: 1,
|
||||
longitude: 1,
|
||||
isActive: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
ownerAdminId: {
|
||||
_id: '$ownerAdmin._id',
|
||||
name: '$ownerAdmin.name',
|
||||
username: '$ownerAdmin.username',
|
||||
email: '$ownerAdmin.email',
|
||||
avatar: '$ownerAdmin.avatar',
|
||||
shopName: '$ownerAdmin.shopName',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countRepairShopsPublic(filter: FilterQuery<RepairShopDocument>): Promise<number> {
|
||||
const rows = await this.repairShopModel
|
||||
.aggregate([
|
||||
{ $match: filter },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'ownerAdminId',
|
||||
foreignField: '_id',
|
||||
as: 'ownerAdmin',
|
||||
},
|
||||
},
|
||||
{ $unwind: '$ownerAdmin' },
|
||||
{ $match: { 'ownerAdmin.isDisabled': false } },
|
||||
{ $count: 'count' },
|
||||
])
|
||||
.exec();
|
||||
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
async countRepairShops(filter: FilterQuery<RepairShopDocument>): Promise<number> {
|
||||
return this.repairShopModel.countDocuments(filter).exec();
|
||||
}
|
||||
}
|
||||
|
||||
تم حذف اختلاف الملف لأن الملف كبير جداً
تحميل الاختلاف
@@ -1,6 +1,9 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { resolveManagedFileUrls } from '../../../common/utils/public-url.util';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum';
|
||||
import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum';
|
||||
|
||||
export type InstrumentDocument = HydratedDocument<Instrument>;
|
||||
|
||||
@@ -29,9 +32,39 @@ export class Instrument {
|
||||
|
||||
@Prop({ default: true, index: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
enum: MarketplaceListingCondition,
|
||||
default: MarketplaceListingCondition.USED,
|
||||
index: true,
|
||||
})
|
||||
condition!: MarketplaceListingCondition;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 80, index: true })
|
||||
instrumentType!: string;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
enum: MarketplaceListingCategory,
|
||||
default: MarketplaceListingCategory.MUSICAL_INSTRUMENT,
|
||||
index: true,
|
||||
})
|
||||
listingCategory!: MarketplaceListingCategory;
|
||||
}
|
||||
|
||||
export const InstrumentSchema = SchemaFactory.createForClass(Instrument);
|
||||
InstrumentSchema.index({ ownerAdminId: 1, createdAt: -1 });
|
||||
InstrumentSchema.index({ isActive: 1, createdAt: -1 });
|
||||
InstrumentSchema.index({ title: 1, isActive: 1, createdAt: -1 });
|
||||
InstrumentSchema.index({ listingCategory: 1, isActive: 1, createdAt: -1 });
|
||||
InstrumentSchema.index({ condition: 1, isActive: 1, createdAt: -1 });
|
||||
InstrumentSchema.index({ instrumentType: 1, isActive: 1, createdAt: -1 });
|
||||
|
||||
const transformManagedInstrumentFiles = (_doc: unknown, ret: any) => {
|
||||
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
|
||||
return ret;
|
||||
};
|
||||
|
||||
InstrumentSchema.set('toJSON', { transform: transformManagedInstrumentFiles });
|
||||
InstrumentSchema.set('toObject', { transform: transformManagedInstrumentFiles });
|
||||
|
||||
55
src/modules/marketplace/schemas/repair-shop.schema.ts
Normal file
55
src/modules/marketplace/schemas/repair-shop.schema.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { resolveManagedFileUrls } from '../../../common/utils/public-url.util';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type RepairShopDocument = HydratedDocument<RepairShop>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class RepairShop {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
ownerAdminId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true, trim: true, maxlength: 120, index: true })
|
||||
name!: string;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 2000 })
|
||||
description!: string;
|
||||
|
||||
@Prop({ type: [String], default: [] })
|
||||
services!: string[];
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 40 })
|
||||
phone!: string;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 40 })
|
||||
whatsapp!: string;
|
||||
|
||||
@Prop({ type: [String], default: [] })
|
||||
imageUrls!: string[];
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 160 })
|
||||
location!: string;
|
||||
|
||||
@Prop({ type: Number, min: -90, max: 90, default: null })
|
||||
latitude!: number | null;
|
||||
|
||||
@Prop({ type: Number, min: -180, max: 180, default: null })
|
||||
longitude!: number | null;
|
||||
|
||||
@Prop({ default: true, index: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
export const RepairShopSchema = SchemaFactory.createForClass(RepairShop);
|
||||
RepairShopSchema.index({ ownerAdminId: 1, createdAt: -1 });
|
||||
RepairShopSchema.index({ isActive: 1, createdAt: -1 });
|
||||
RepairShopSchema.index({ name: 1, isActive: 1, createdAt: -1 });
|
||||
|
||||
const transformManagedRepairShopFiles = (_doc: unknown, ret: any) => {
|
||||
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
|
||||
return ret;
|
||||
};
|
||||
|
||||
RepairShopSchema.set('toJSON', { transform: transformManagedRepairShopFiles });
|
||||
RepairShopSchema.set('toObject', { transform: transformManagedRepairShopFiles });
|
||||
23
src/modules/media/dto/text-to-music.dto.ts
Normal file
23
src/modules/media/dto/text-to-music.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Max, Min } from 'class-validator';
|
||||
|
||||
export class TextToMusicDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
prompt!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(5)
|
||||
@Max(30)
|
||||
durationSeconds?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(2147483647)
|
||||
seed?: number;
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { TextToMusicDto } from './dto/text-to-music.dto';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
@ApiTags('Media')
|
||||
@Controller('media')
|
||||
export class MediaController {}
|
||||
export class MediaController {
|
||||
constructor(private readonly mediaService: MediaService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('ai/text-to-music')
|
||||
@Throttle(6, 60_000)
|
||||
async generateMusicFromText(@CurrentUser() user: JwtPayload, @Body() dto: TextToMusicDto) {
|
||||
return this.mediaService.generateMusicFromText(user.sub, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,133 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util';
|
||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import { TextToMusicDto } from './dto/text-to-music.dto';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {}
|
||||
export class MediaService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly storageService: ManagedStorageService,
|
||||
) {}
|
||||
|
||||
async generateMusicFromText(userId: string, dto: TextToMusicDto) {
|
||||
const enabled = this.configService.get<boolean>('aiMusic.enabled', { infer: true });
|
||||
if (!enabled) {
|
||||
throw new ServiceUnavailableException('AI music generation is disabled');
|
||||
}
|
||||
|
||||
const apiKey = this.configService.get<string>('aiMusic.apiKey', { infer: true }) ?? '';
|
||||
const projectId = this.configService.get<string>('aiMusic.projectId', { infer: true }) ?? '';
|
||||
const location = this.configService.get<string>('aiMusic.location', { infer: true }) ?? '';
|
||||
const model = this.configService.get<string>('aiMusic.model', { infer: true }) ?? 'lyria-002';
|
||||
if (!projectId || !location) {
|
||||
throw new ServiceUnavailableException('AI music settings are not configured');
|
||||
}
|
||||
|
||||
let url = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:predict`;
|
||||
let authorizationHeader: string | null = null;
|
||||
|
||||
if (apiKey) {
|
||||
url = `${url}?key=${encodeURIComponent(apiKey)}`;
|
||||
} else {
|
||||
const auth = new GoogleAuth({
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
});
|
||||
|
||||
const client = await auth.getClient();
|
||||
const accessTokenRaw = await client.getAccessToken();
|
||||
const accessToken =
|
||||
typeof accessTokenRaw === 'string' ? accessTokenRaw : accessTokenRaw?.token ?? '';
|
||||
|
||||
if (!accessToken) {
|
||||
throw new ServiceUnavailableException('Failed to authenticate with Google Cloud');
|
||||
}
|
||||
authorizationHeader = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
instances: [{ prompt: dto.prompt }],
|
||||
parameters: {
|
||||
sampleCount: 1,
|
||||
durationSeconds: dto.durationSeconds ?? 12,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof dto.seed === 'number') {
|
||||
(requestBody.parameters as Record<string, unknown>).seed = dto.seed;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(authorizationHeader ? { Authorization: authorizationHeader } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as {
|
||||
error?: { message?: string };
|
||||
predictions?: Array<{
|
||||
bytesBase64Encoded?: string;
|
||||
mimeType?: string;
|
||||
audio?: { bytesBase64Encoded?: string; mimeType?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BadGatewayException(
|
||||
result?.error?.message ?? 'Google AI returned an unexpected error',
|
||||
);
|
||||
}
|
||||
|
||||
const first = result?.predictions?.[0];
|
||||
const audioBase64 = first?.bytesBase64Encoded ?? first?.audio?.bytesBase64Encoded ?? '';
|
||||
const mimeType = first?.mimeType ?? first?.audio?.mimeType ?? 'audio/wav';
|
||||
|
||||
if (!audioBase64) {
|
||||
throw new BadGatewayException('Google AI did not return audio content');
|
||||
}
|
||||
|
||||
const extension = this.resolveAudioExtension(mimeType);
|
||||
const buffer = Buffer.from(audioBase64, 'base64');
|
||||
const audioUrl = await this.storageService.saveFile({
|
||||
folderSegments: ['ai-music'],
|
||||
extension: `.${extension}`,
|
||||
buffer,
|
||||
contentType: mimeType,
|
||||
fileNamePrefix: `ai-${userId}`,
|
||||
});
|
||||
|
||||
return {
|
||||
prompt: dto.prompt,
|
||||
durationSeconds: dto.durationSeconds ?? 12,
|
||||
mimeType,
|
||||
sizeBytes: buffer.length,
|
||||
audioUrl,
|
||||
waveformPeaks: generateWaveformPeaksFromBuffer(buffer),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveAudioExtension(mimeType: string): string {
|
||||
if (mimeType.includes('mpeg') || mimeType.includes('mp3')) {
|
||||
return 'mp3';
|
||||
}
|
||||
if (mimeType.includes('ogg')) {
|
||||
return 'ogg';
|
||||
}
|
||||
if (mimeType.includes('aac')) {
|
||||
return 'aac';
|
||||
}
|
||||
if (mimeType.includes('wav')) {
|
||||
return 'wav';
|
||||
}
|
||||
return 'wav';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsEnum, IsMongoId, IsOptional } from 'class-validator';
|
||||
import { IsEnum, IsMongoId, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { NOTIFICATION_TYPES, NotificationType } from '../schemas/notification.schema';
|
||||
|
||||
export class CreateNotificationDto {
|
||||
@IsMongoId()
|
||||
@@ -7,10 +8,34 @@ export class CreateNotificationDto {
|
||||
@IsMongoId()
|
||||
actorId!: string;
|
||||
|
||||
@IsEnum(['like', 'comment', 'follow', 'message'])
|
||||
type!: 'like' | 'comment' | 'follow' | 'message';
|
||||
@IsEnum(NOTIFICATION_TYPES)
|
||||
type!: NotificationType;
|
||||
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
referenceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
previewText?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
resourceType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(240)
|
||||
deepLink?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
import { NOTIFICATION_TYPES, NotificationType } from '../schemas/notification.schema';
|
||||
|
||||
export class NotificationQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === 'true' || value === true) return true;
|
||||
if (value === 'false' || value === false) return false;
|
||||
return value;
|
||||
})
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
read?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: NOTIFICATION_TYPES })
|
||||
@IsOptional()
|
||||
@IsEnum(NOTIFICATION_TYPES)
|
||||
type?: NotificationType;
|
||||
|
||||
@ApiPropertyOptional({ example: 'comment' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resourceType?: string;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
import { Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
import { NotificationQueryDto } from './dto/notification-query.dto';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.NOTIFICATIONS_READ)
|
||||
@Get('superadmin')
|
||||
async getForSuperAdmin(@Query() query: NotificationQueryDto) {
|
||||
return this.notificationsService.getForSuperAdmin(query);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
async getMine(@CurrentUser() user: JwtPayload, @Query() query: NotificationQueryDto) {
|
||||
return this.notificationsService.getMine(user.sub, query);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('unread-count')
|
||||
async getUnreadCount(@CurrentUser() user: JwtPayload) {
|
||||
return this.notificationsService.getUnreadCount(user.sub);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('read-all')
|
||||
async markAllRead(@CurrentUser() user: JwtPayload) {
|
||||
return this.notificationsService.markAllRead(user.sub);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':id/read')
|
||||
async markRead(@CurrentUser() user: JwtPayload, @Param('id') notificationId: string) {
|
||||
return this.notificationsService.markRead(user.sub, notificationId);
|
||||
|
||||
@@ -31,6 +31,7 @@ export class NotificationsRepository {
|
||||
filter: FilterQuery<NotificationDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<NotificationDocument[]> {
|
||||
return this.notificationModel
|
||||
.find({
|
||||
@@ -38,7 +39,22 @@ export class NotificationsRepository {
|
||||
...filter,
|
||||
})
|
||||
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ createdAt: -1 })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findMany(
|
||||
filter: FilterQuery<NotificationDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<NotificationDocument[]> {
|
||||
return this.notificationModel
|
||||
.find(filter)
|
||||
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
@@ -62,6 +78,19 @@ export class NotificationsRepository {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async count(filter: FilterQuery<NotificationDocument>): Promise<number> {
|
||||
return this.notificationModel.countDocuments(filter).exec();
|
||||
}
|
||||
|
||||
async countUnreadAll(filter: FilterQuery<NotificationDocument> = {}): Promise<number> {
|
||||
return this.notificationModel
|
||||
.countDocuments({
|
||||
...filter,
|
||||
read: false,
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async markRead(recipientId: string, notificationId: string): Promise<NotificationDocument | null> {
|
||||
const updated = await this.notificationModel
|
||||
.findOneAndUpdate(
|
||||
|
||||
@@ -2,6 +2,35 @@ import { NotFoundException } from '@nestjs/common';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
describe('NotificationsService', () => {
|
||||
it('creates mention notifications with mention type', async () => {
|
||||
const notificationsRepository = {
|
||||
create: jest.fn().mockResolvedValue({ toJSON: () => ({ _id: 'notification-1' }) }),
|
||||
countUnread: jest.fn().mockResolvedValue(5),
|
||||
};
|
||||
const notificationsGateway = {
|
||||
emitCreated: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new NotificationsService(
|
||||
notificationsRepository as any,
|
||||
notificationsGateway as any,
|
||||
);
|
||||
|
||||
await service.createMentionNotification(
|
||||
'507f1f77bcf86cd799439011',
|
||||
'507f191e810c19729de860ea',
|
||||
'507f1f77bcf86cd799439012',
|
||||
{ previewText: 'Hello @rami' },
|
||||
);
|
||||
|
||||
expect(notificationsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'mention',
|
||||
previewText: 'Hello @rami',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('recalculates unread count after markAllRead', async () => {
|
||||
const notificationsRepository = {
|
||||
markAllRead: jest.fn().mockResolvedValue(4),
|
||||
|
||||
لم تُعرض بعض الملفات لأن الكثير من الملفات تغيرت في هذا الاختلاف إظهار المزيد
المرجع في مشكلة جديدة
حظر مستخدم