هذا الالتزام موجود في:
2026-04-20 15:12:16 +03:00
التزام 28f7241bcd
172 ملفات معدلة مع 21907 إضافات و0 حذوفات

44
.env.example Normal file
عرض الملف

@@ -0,0 +1,44 @@
NODE_ENV=development
PORT=4000
HOST=0.0.0.0
PUBLIC_BASE_URL=http://localhost:4000
RESPONSE_ENVELOPE_ENABLED=false
GLOBAL_PREFIX=api/v1
CORS_ORIGINS=http://localhost:3000,http://192.168.1.14:3000,http://192.168.1.14:5173
MONGODB_URI=mongodb://127.0.0.1:27017/oudelaa
JWT_ACCESS_SECRET=change_me_access_secret
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_SECRET=change_me_refresh_secret
JWT_REFRESH_EXPIRES_IN=30d
BCRYPT_SALT_ROUNDS=12
PASSWORD_RESET_CODE_EXPIRES_MINUTES=10
PASSWORD_RESET_MAX_ATTEMPTS=5
PASSWORD_RESET_TOKEN_SECRET=
PASSWORD_RESET_TOKEN_EXPIRES_IN=15m
EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES=10
EMAIL_VERIFICATION_MAX_ATTEMPTS=5
SWAGGER_TITLE=Oudelaa API
SWAGGER_DESCRIPTION=Social media backend API documentation
SWAGGER_VERSION=1.0.0
SWAGGER_PATH=docs
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GOOGLE_CALLBACK_URL=http://192.168.1.14:4000/api/v1/auth/google/callback
EMAIL_ENABLED=false
EMAIL_SMTP_HOST=smtp.gmail.com
EMAIL_SMTP_PORT=587
EMAIL_SMTP_SECURE=false
EMAIL_SMTP_USER=
EMAIL_SMTP_PASS=
EMAIL_FROM_NAME=Oudelaa
EMAIL_FROM_EMAIL=
SUPERADMIN_EMAIL=admin@oudelaa.com
SUPERADMIN_PASSWORD=SuperAdminStrongPass123!
SUPERADMIN_ACCESS_SECRET=change_me_superadmin_access_secret
SUPERADMIN_ACCESS_EXPIRES_IN=15m
SUPERADMIN_REFRESH_SECRET=change_me_superadmin_refresh_secret
SUPERADMIN_REFRESH_EXPIRES_IN=30d

12
.gitignore مباع Normal file
عرض الملف

@@ -0,0 +1,12 @@
# Build
node_modules
dist
uploads
# Env files
.env
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*

6
.prettierrc Normal file
عرض الملف

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": true
}

3
README.md Normal file
عرض الملف

@@ -0,0 +1,3 @@
# Oudelaa Backend
Production-oriented NestJS backend for a social media platform.

11
jest.config.js Normal file
عرض الملف

@@ -0,0 +1,11 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testEnvironment: 'node',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['src/**/*.(t|j)s'],
coverageDirectory: './coverage',
};

8
nest-cli.json Normal file
عرض الملف

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

10872
package-lock.json مولّد Normal file

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

67
package.json Normal file
عرض الملف

@@ -0,0 +1,67 @@
{
"name": "oudelaa-backend",
"version": "1.0.0",
"private": true,
"description": "Production-ready social media backend with NestJS and MongoDB",
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"lint": "eslint \"src/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mongoose": "^10.1.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/platform-socket.io": "^10.4.0",
"@nestjs/swagger": "^8.1.0",
"@nestjs/websockets": "^10.4.0",
"@types/passport-google-oauth20": "^2.0.17",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"google-auth-library": "^10.6.2",
"joi": "^17.13.3",
"mongoose": "^8.6.0",
"nodemailer": "^8.0.5",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.16.5",
"@types/nodemailer": "^8.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.11.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"prettier": "^3.3.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.2"
}
}

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

عرض الملف

@@ -0,0 +1,39 @@
{
"id": "c5f5b9cc-9e95-4e19-9a76-cc8f6b95a001",
"name": "Oudelaa Local",
"values": [
{
"key": "baseUrl",
"value": "http://localhost:4000",
"type": "default",
"enabled": true
},
{
"key": "accessToken",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "refreshToken",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "postId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "userId",
"value": "",
"type": "default",
"enabled": true
}
],
"_postman_variable_scope": "environment",
"_postman_exported_at": "2026-04-06T00:00:00.000Z",
"_postman_exported_using": "Codex"
}

عرض الملف

@@ -0,0 +1,209 @@
{
"dashboard": {
"name": "Oudelaa SuperAdmin Dashboard",
"version": "1.2.0",
"baseUrl": "{{baseUrl}}",
"auth": {
"type": "Bearer",
"login": {
"method": "POST",
"url": "/auth/superadmin/login",
"body": {
"email": "admin@oudelaa.com",
"password": "SuperAdminStrongPass123!"
},
"responseTokens": {
"accessToken": "superAdminAccessToken",
"refreshToken": "superAdminRefreshToken"
}
},
"refresh": {
"method": "POST",
"url": "/auth/superadmin/refresh",
"body": {
"refreshToken": "{{superAdminRefreshToken}}"
}
},
"logout": {
"method": "POST",
"url": "/auth/superadmin/logout",
"body": {
"refreshToken": "{{superAdminRefreshToken}}"
}
}
},
"modules": [
{
"key": "usersModeration",
"title": "Users Moderation",
"endpoints": [
{
"name": "Admin Get Users",
"method": "GET",
"url": "/users/admin?page=1&limit=10",
"headers": {
"Authorization": "Bearer {{superAdminAccessToken}}"
}
},
{
"name": "Admin Get User By Id",
"method": "GET",
"url": "/users/admin/:userId",
"headers": {
"Authorization": "Bearer {{superAdminAccessToken}}"
}
},
{
"name": "Admin Update User",
"method": "PATCH",
"url": "/users/admin/:userId",
"headers": {
"Authorization": "Bearer {{superAdminAccessToken}}",
"Content-Type": "application/json"
},
"body": {
"stageName": "Updated by SuperAdmin",
"bio": "Profile updated by admin"
}
},
{
"name": "Disable User",
"method": "PATCH",
"url": "/users/admin/:userId/disable",
"headers": {
"Authorization": "Bearer {{superAdminAccessToken}}",
"Content-Type": "application/json"
},
"body": {
"reason": "Violation of community guidelines"
}
},
{
"name": "Enable User",
"method": "PATCH",
"url": "/users/admin/:userId/enable",
"headers": {
"Authorization": "Bearer {{superAdminAccessToken}}"
}
},
{
"name": "Delete User",
"method": "DELETE",
"url": "/users/admin/:userId",
"headers": {
"Authorization": "Bearer {{superAdminAccessToken}}"
}
}
]
},
{
"key": "commentsModeration",
"title": "Comments Moderation",
"endpoints": [
{
"name": "Admin Delete Comment",
"method": "DELETE",
"url": "/comments/admin/:commentId",
"headers": {
"Authorization": "Bearer {{superAdminAccessToken}}"
}
}
]
},
{
"key": "securitySessions",
"title": "Security & Sessions",
"endpoints": [
{
"name": "My Sessions",
"method": "GET",
"url": "/auth/sessions",
"headers": {
"Authorization": "Bearer {{accessToken}}"
}
},
{
"name": "Revoke Session",
"method": "POST",
"url": "/auth/sessions/:jti/revoke",
"headers": {
"Authorization": "Bearer {{accessToken}}"
}
}
]
},
{
"key": "feedAlgorithms",
"title": "Feed Algorithms",
"endpoints": [
{
"name": "My Feed",
"method": "GET",
"url": "/feed/me?page=1&limit=20&radiusKm=30",
"headers": {
"Authorization": "Bearer {{accessToken}}"
}
},
{
"name": "My Feed Preferred",
"method": "GET",
"url": "/feed/me?page=1&limit=20&preferredPostType=video&followingOnly=false&radiusKm=50",
"headers": {
"Authorization": "Bearer {{accessToken}}"
}
},
{
"name": "Trending Feed",
"method": "GET",
"url": "/feed/trending?page=1&limit=20",
"headers": {
"Authorization": "Bearer {{accessToken}}"
}
}
]
}
],
"state": {
"tokens": [
"superAdminAccessToken",
"superAdminRefreshToken",
"sessionJti",
"targetUserId",
"conversationId",
"messageId",
"accessToken"
],
"selectedUser": "userId",
"selectedComment": "commentId"
},
"ui": {
"pages": [
"SuperAdmin Login",
"Users List",
"User Profile",
"Update User",
"Disable User Modal",
"Delete User Confirmation",
"Comments Moderation",
"Feed Ranking",
"Security Sessions"
],
"tables": [
{
"id": "users",
"columns": [
"_id",
"name",
"stageName",
"username",
"email",
"role",
"isDisabled",
"disabledReason",
"createdAt"
]
}
]
}
}
}

12
src/app.controller.ts Normal file
عرض الملف

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller('health')
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHealth(): { status: string; service: string } {
return this.appService.getHealth();
}
}

58
src/app.module.ts Normal file
عرض الملف

@@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import configuration from './config/configuration';
import { validationSchema } from './config/validation.schema';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './modules/auth/auth.module';
import { AuditModule } from './modules/audit/audit.module';
import { ChatModule } from './modules/chat/chat.module';
import { CommentsModule } from './modules/comments/comments.module';
import { FeedModule } from './modules/feed/feed.module';
import { FollowsModule } from './modules/follows/follows.module';
import { LikesModule } from './modules/likes/likes.module';
import { MediaModule } from './modules/media/media.module';
import { MarketplaceModule } from './modules/marketplace/marketplace.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { OutboxModule } from './modules/outbox/outbox.module';
import { PostsModule } from './modules/posts/posts.module';
import { SavesModule } from './modules/saves/saves.module';
import { UsersModule } from './modules/users/users.module';
import { ThrottleGuard } from './common/guards/throttle.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
load: [configuration],
validationSchema,
}),
DatabaseModule,
AuditModule,
UsersModule,
AuthModule,
PostsModule,
CommentsModule,
LikesModule,
FollowsModule,
FeedModule,
NotificationsModule,
OutboxModule,
ChatModule,
MediaModule,
MarketplaceModule,
SavesModule,
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: ThrottleGuard,
},
],
})
export class AppModule {}

8
src/app.service.ts Normal file
عرض الملف

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHealth(): { status: string; service: string } {
return { status: 'ok', service: 'oudelaa-backend' };
}
}

عرض الملف

@@ -0,0 +1,6 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
export const CurrentUser = createParamDecorator((_: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
return request.user;
});

عرض الملف

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = (): ReturnType<typeof SetMetadata> => SetMetadata(IS_PUBLIC_KEY, true);

عرض الملف

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]): ReturnType<typeof SetMetadata> =>
SetMetadata(ROLES_KEY, roles);

عرض الملف

@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
export const THROTTLE_META_KEY = 'throttle_meta_key';
export type ThrottleMeta = {
limit: number;
windowMs: number;
};
export const Throttle = (limit: number, windowMs: number) =>
SetMetadata(THROTTLE_META_KEY, { limit, windowMs } satisfies ThrottleMeta);

عرض الملف

@@ -0,0 +1,6 @@
import { IsMongoId } from 'class-validator';
export class ObjectIdParamDto {
@IsMongoId()
id!: string;
}

عرض الملف

@@ -0,0 +1,22 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { APP_CONSTANTS } from '../../config/constants';
export class PaginationQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = APP_CONSTANTS.DEFAULT_PAGE;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(APP_CONSTANTS.MAX_LIMIT)
limit?: number = APP_CONSTANTS.DEFAULT_LIMIT;
@IsOptional()
@IsString()
cursor?: string;
}

عرض الملف

@@ -0,0 +1,6 @@
export enum ExperienceLevel {
BEGINNER = 'beginner',
INTERMEDIATE = 'intermediate',
ADVANCED = 'advanced',
PROFESSIONAL = 'professional',
}

عرض الملف

@@ -0,0 +1,8 @@
export enum MusicRole {
INSTRUMENTALIST = 'instrumentalist',
SINGER = 'singer',
COMPOSER = 'composer',
LYRICIST = 'lyricist',
PRODUCER = 'producer',
ARRANGER = 'arranger',
}

عرض الملف

@@ -0,0 +1,6 @@
export enum NotificationType {
LIKE = 'like',
COMMENT = 'comment',
FOLLOW = 'follow',
MESSAGE = 'message',
}

عرض الملف

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

عرض الملف

@@ -0,0 +1,5 @@
export enum PostVisibility {
PUBLIC = 'public',
FOLLOWERS = 'followers',
PRIVATE = 'private',
}

عرض الملف

@@ -0,0 +1,4 @@
export enum TokenType {
ACCESS = 'access',
REFRESH = 'refresh',
}

عرض الملف

@@ -0,0 +1,5 @@
export enum UserRole {
USER = 'user',
ADMIN = 'admin',
SUPERADMIN = 'superadmin',
}

عرض الملف

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

عرض الملف

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}

عرض الملف

@@ -0,0 +1,29 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const payload = request.user ?? {};
const userRoles: string[] = Array.isArray(payload.roles)
? payload.roles
: payload.role
? [payload.role]
: [];
return requiredRoles.some((role) => userRoles.includes(role));
}
}

عرض الملف

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class SuperAdminJwtAuthGuard extends AuthGuard('superadmin-jwt') {}

عرض الملف

@@ -0,0 +1,50 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { THROTTLE_META_KEY, ThrottleMeta } from '../decorators/throttle.decorator';
type Bucket = {
count: number;
resetAt: number;
};
@Injectable()
export class ThrottleGuard implements CanActivate {
private readonly buckets = new Map<string, Bucket>();
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const meta = this.reflector.getAllAndOverride<ThrottleMeta>(THROTTLE_META_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!meta) {
return true;
}
const req = context.switchToHttp().getRequest<Request & { ip?: string; originalUrl?: string }>();
const ip = req.ip ?? 'unknown';
const route = req.originalUrl ?? 'unknown-route';
const key = `${ip}:${route}`;
const now = Date.now();
const existing = this.buckets.get(key);
if (!existing || now > existing.resetAt) {
this.buckets.set(key, { count: 1, resetAt: now + meta.windowMs });
return true;
}
if (existing.count >= meta.limit) {
throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS);
}
existing.count += 1;
return true;
}
}

عرض الملف

@@ -0,0 +1,25 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';
@Injectable()
export class ResponseEnvelopeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const response = http.getResponse<{ statusCode?: number }>();
return next.handle().pipe(
map((data) => ({
data,
meta: {
statusCode: response.statusCode ?? 200,
timestamp: new Date().toISOString(),
},
})),
);
}
}

عرض الملف

@@ -0,0 +1,7 @@
export interface JwtPayload {
sub: string;
username: string;
role?: string;
tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh';
email?: string;
}

عرض الملف

@@ -0,0 +1,13 @@
import { decodeOffsetCursor, encodeOffsetCursor } from './cursor.util';
describe('cursor util', () => {
it('encodes and decodes cursor offsets', () => {
const cursor = encodeOffsetCursor(40);
expect(decodeOffsetCursor(cursor)).toBe(40);
});
it('returns null on invalid cursor', () => {
expect(decodeOffsetCursor('%%%invalid%%%')).toBeNull();
expect(decodeOffsetCursor(encodeOffsetCursor(-1))).toBeNull();
});
});

عرض الملف

@@ -0,0 +1,19 @@
export const encodeOffsetCursor = (offset: number): string =>
Buffer.from(String(offset), 'utf8').toString('base64url');
export const decodeOffsetCursor = (cursor?: string): number | null => {
if (!cursor) {
return null;
}
try {
const raw = Buffer.from(cursor, 'base64url').toString('utf8');
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 0) {
return null;
}
return parsed;
} catch {
return null;
}
};

عرض الملف

@@ -0,0 +1,7 @@
import * as bcrypt from 'bcrypt';
export const hashValue = async (value: string, saltRounds: number): Promise<string> =>
bcrypt.hash(value, saltRounds);
export const compareHash = async (value: string, hashedValue: string): Promise<boolean> =>
bcrypt.compare(value, hashedValue);

عرض الملف

@@ -0,0 +1,69 @@
export default () => ({
nodeEnv: process.env.NODE_ENV ?? 'development',
port: Number(process.env.PORT ?? 4000),
host: process.env.HOST ?? '0.0.0.0',
publicBaseUrl:
process.env.PUBLIC_BASE_URL ??
`http://localhost:${Number(process.env.PORT ?? 4000)}`,
responseEnvelopeEnabled:
(process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true',
globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1',
cors: {
origins: (process.env.CORS_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0),
},
mongodb: {
uri: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/oudelaa',
},
jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET ?? '',
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN ?? '15m',
refreshSecret: process.env.JWT_REFRESH_SECRET ?? '',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '30d',
},
superAdmin: {
email: (process.env.SUPERADMIN_EMAIL ?? '').toLowerCase(),
password: process.env.SUPERADMIN_PASSWORD ?? '',
accessSecret: process.env.SUPERADMIN_ACCESS_SECRET ?? '',
accessExpiresIn: process.env.SUPERADMIN_ACCESS_EXPIRES_IN ?? '15m',
refreshSecret: process.env.SUPERADMIN_REFRESH_SECRET ?? '',
refreshExpiresIn: process.env.SUPERADMIN_REFRESH_EXPIRES_IN ?? '30d',
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',
callbackUrl:
process.env.GOOGLE_CALLBACK_URL ?? 'http://localhost:4000/api/v1/auth/google/callback',
},
email: {
enabled: (process.env.EMAIL_ENABLED ?? 'false').toLowerCase() === 'true',
smtpHost: process.env.EMAIL_SMTP_HOST ?? 'smtp.gmail.com',
smtpPort: Number(process.env.EMAIL_SMTP_PORT ?? 587),
smtpSecure: (process.env.EMAIL_SMTP_SECURE ?? 'false').toLowerCase() === 'true',
smtpUser: process.env.EMAIL_SMTP_USER ?? '',
smtpPass: process.env.EMAIL_SMTP_PASS ?? '',
fromName: process.env.EMAIL_FROM_NAME ?? 'Oudelaa',
fromEmail: process.env.EMAIL_FROM_EMAIL ?? process.env.EMAIL_SMTP_USER ?? '',
},
security: {
bcryptSaltRounds: Number(process.env.BCRYPT_SALT_ROUNDS ?? 12),
},
passwordReset: {
codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10),
maxAttempts: Number(process.env.PASSWORD_RESET_MAX_ATTEMPTS ?? 5),
tokenSecret: process.env.PASSWORD_RESET_TOKEN_SECRET ?? process.env.JWT_ACCESS_SECRET ?? '',
tokenExpiresIn: process.env.PASSWORD_RESET_TOKEN_EXPIRES_IN ?? '15m',
},
emailVerification: {
codeExpiresMinutes: Number(process.env.EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES ?? 10),
maxAttempts: Number(process.env.EMAIL_VERIFICATION_MAX_ATTEMPTS ?? 5),
},
swagger: {
title: process.env.SWAGGER_TITLE ?? 'Oudelaa API',
description: process.env.SWAGGER_DESCRIPTION ?? 'Social media backend API documentation',
version: process.env.SWAGGER_VERSION ?? '1.0.0',
path: process.env.SWAGGER_PATH ?? 'docs',
},
});

5
src/config/constants.ts Normal file
عرض الملف

@@ -0,0 +1,5 @@
export const APP_CONSTANTS = {
DEFAULT_PAGE: 1,
DEFAULT_LIMIT: 20,
MAX_LIMIT: 100,
};

عرض الملف

@@ -0,0 +1,17 @@
import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
export const setupSwagger = (app: INestApplication, configService: ConfigService): void => {
const config = new DocumentBuilder()
.setTitle(configService.get<string>('swagger.title', 'Oudelaa API'))
.setDescription(
configService.get<string>('swagger.description', 'Social media backend API documentation'),
)
.setVersion(configService.get<string>('swagger.version', '1.0.0'))
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup(configService.get<string>('swagger.path', 'docs'), app, document);
};

عرض الملف

@@ -0,0 +1,44 @@
import * as Joi from 'joi';
export const validationSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
PORT: Joi.number().default(4000),
HOST: Joi.string().default('0.0.0.0'),
PUBLIC_BASE_URL: Joi.string().uri().optional(),
RESPONSE_ENVELOPE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
GLOBAL_PREFIX: Joi.string().default('api/v1'),
CORS_ORIGINS: Joi.string().allow('').optional(),
MONGODB_URI: Joi.string().required(),
JWT_ACCESS_SECRET: Joi.string().min(16).required(),
JWT_ACCESS_EXPIRES_IN: Joi.string().default('15m'),
JWT_REFRESH_SECRET: Joi.string().min(16).required(),
JWT_REFRESH_EXPIRES_IN: Joi.string().default('30d'),
SUPERADMIN_EMAIL: Joi.string().email().required(),
SUPERADMIN_PASSWORD: Joi.string().min(8).required(),
SUPERADMIN_ACCESS_SECRET: Joi.string().min(16).required(),
SUPERADMIN_ACCESS_EXPIRES_IN: Joi.string().default('15m'),
SUPERADMIN_REFRESH_SECRET: Joi.string().min(16).required(),
SUPERADMIN_REFRESH_EXPIRES_IN: Joi.string().default('30d'),
GOOGLE_CLIENT_ID: Joi.string().allow('').optional(),
GOOGLE_CLIENT_SECRET: Joi.string().allow('').optional(),
GOOGLE_CALLBACK_URL: Joi.string().uri().optional(),
EMAIL_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
EMAIL_SMTP_HOST: Joi.string().allow('').optional(),
EMAIL_SMTP_PORT: Joi.number().default(587),
EMAIL_SMTP_SECURE: Joi.boolean().truthy('true').falsy('false').default(false),
EMAIL_SMTP_USER: Joi.string().allow('').optional(),
EMAIL_SMTP_PASS: Joi.string().allow('').optional(),
EMAIL_FROM_NAME: Joi.string().default('Oudelaa'),
EMAIL_FROM_EMAIL: Joi.string().allow('').optional(),
BCRYPT_SALT_ROUNDS: Joi.number().min(8).max(15).default(12),
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(),
PASSWORD_RESET_TOKEN_EXPIRES_IN: Joi.string().default('15m'),
EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
EMAIL_VERIFICATION_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
SWAGGER_TITLE: Joi.string().default('Oudelaa API'),
SWAGGER_DESCRIPTION: Joi.string().default('Social media backend API documentation'),
SWAGGER_VERSION: Joi.string().default('1.0.0'),
SWAGGER_PATH: Joi.string().default('docs'),
});

عرض الملف

@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { createMongooseOptions } from './mongoose-options.factory';
@Global()
@Module({
imports: [
MongooseModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => createMongooseOptions(configService),
}),
],
exports: [MongooseModule],
})
export class DatabaseModule {}

عرض الملف

@@ -0,0 +1,7 @@
import { ConfigService } from '@nestjs/config';
import { MongooseModuleOptions } from '@nestjs/mongoose';
export const createMongooseOptions = (configService: ConfigService): MongooseModuleOptions => ({
uri: configService.get<string>('mongodb.uri', { infer: true }),
autoIndex: true,
});

84
src/main.ts Normal file
عرض الملف

@@ -0,0 +1,84 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as express from 'express';
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
import { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { AppModule } from './app.module';
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const corsOrigins = configService.get<string[]>('cors.origins', []);
const uploadsDir = join(process.cwd(), 'uploads');
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true });
}
app.enableCors({
origin: corsOrigins.length ? corsOrigins : true,
credentials: true,
});
app.setGlobalPrefix(configService.get<string>('globalPrefix', 'api/v1'));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
app.use((req: Request, res: Response, next: NextFunction) => {
const startedAt = Date.now();
const requestId = (req.headers['x-request-id'] as string | undefined) ?? randomUUID();
req.headers['x-request-id'] = requestId;
res.setHeader('x-request-id', requestId);
res.on('finish', () => {
const log = {
level: 'info',
requestId,
method: req.method,
path: req.originalUrl,
statusCode: res.statusCode,
durationMs: Date.now() - startedAt,
};
console.log(JSON.stringify(log));
});
next();
});
const responseEnvelopeEnabled = configService.get<boolean>('responseEnvelopeEnabled', false);
if (responseEnvelopeEnabled) {
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
}
app.use('/uploads', express.static(uploadsDir));
const swaggerConfig = new DocumentBuilder()
.setTitle(configService.get<string>('swagger.title', 'Oudelaa API'))
.setDescription(
configService.get<string>('swagger.description', 'Social media backend API documentation'),
)
.setVersion(configService.get<string>('swagger.version', '1.0.0'))
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup(configService.get<string>('swagger.path', 'docs'), app, document);
const port = configService.get<number>('port', 4000);
const host = configService.get<string>('host', '0.0.0.0');
await app.listen(port, host);
}
void bootstrap();

عرض الملف

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuditRepository } from './audit.repository';
import { AuditService } from './audit.service';
import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
@Module({
imports: [
MongooseModule.forFeature([
{
name: AuditLog.name,
schema: AuditLogSchema,
},
]),
],
providers: [AuditRepository, AuditService],
exports: [AuditService],
})
export class AuditModule {}

عرض الملف

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { AuditLog, AuditLogDocument } from './schemas/audit-log.schema';
@Injectable()
export class AuditRepository {
constructor(@InjectModel(AuditLog.name) private readonly auditModel: Model<AuditLogDocument>) {}
async create(payload: {
actorType: 'user' | 'superadmin' | 'system';
actorUserId?: string;
actorIdentifier?: string;
action: string;
targetType: string;
targetId?: string;
metadata?: Record<string, unknown>;
}): Promise<void> {
await this.auditModel.create({
actorType: payload.actorType,
...(payload.actorUserId ? { actorUserId: new Types.ObjectId(payload.actorUserId) } : {}),
actorIdentifier: payload.actorIdentifier,
action: payload.action,
targetType: payload.targetType,
targetId: payload.targetId,
metadata: payload.metadata ?? {},
});
}
}

عرض الملف

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { AuditRepository } from './audit.repository';
@Injectable()
export class AuditService {
constructor(private readonly auditRepository: AuditRepository) {}
async logSuperAdminAction(
actorIdentifier: string,
action: string,
targetType: string,
targetId?: string,
metadata?: Record<string, unknown>,
): Promise<void> {
await this.auditRepository.create({
actorType: 'superadmin',
actorIdentifier,
action,
targetType,
targetId,
metadata,
});
}
}

عرض الملف

@@ -0,0 +1,31 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
export type AuditLogDocument = HydratedDocument<AuditLog>;
@Schema({ timestamps: true, versionKey: false })
export class AuditLog {
@Prop({ required: true, index: true })
actorType!: 'user' | 'superadmin' | 'system';
@Prop({ type: Types.ObjectId, required: false, index: true })
actorUserId?: Types.ObjectId;
@Prop({ type: String, required: false, index: true })
actorIdentifier?: string;
@Prop({ required: true, index: true })
action!: string;
@Prop({ required: true, index: true })
targetType!: string;
@Prop({ type: String, required: false, index: true })
targetId?: string;
@Prop({ type: Object, default: {} })
metadata!: Record<string, unknown>;
}
export const AuditLogSchema = SchemaFactory.createForClass(AuditLog);
AuditLogSchema.index({ createdAt: -1, action: 1 });

عرض الملف

@@ -0,0 +1,154 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Req, UseGuards } from '@nestjs/common';
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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { AuthService } from './auth.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { GoogleTokenLoginDto } from './dto/google-token-login.dto';
import { LoginDto } from './dto/login.dto';
import { RegisterBasicDto } from './dto/register-basic.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { GoogleAuthGuard } from './guards/google-auth.guard';
import { RegisterDto } from './dto/register.dto';
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';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@Throttle(10, 60_000)
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('register-basic')
@Throttle(10, 60_000)
async registerBasic(@Body() dto: RegisterBasicDto) {
return this.authService.registerBasic(dto);
}
@HttpCode(HttpStatus.OK)
@Post('login')
@Throttle(20, 60_000)
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@HttpCode(HttpStatus.OK)
@Post('refresh')
@Throttle(30, 60_000)
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refresh(dto);
}
@HttpCode(HttpStatus.OK)
@Post('logout')
async logout(@Body() dto: RefreshTokenDto): Promise<{ message: string }> {
await this.authService.logout(dto);
return { message: 'Logged out successfully' };
}
@HttpCode(HttpStatus.OK)
@Post('forgot-password')
@Throttle(8, 60_000)
async forgotPassword(@Body() dto: ForgotPasswordDto) {
return this.authService.forgotPassword(dto);
}
@HttpCode(HttpStatus.OK)
@Post('verify-reset-code')
@Throttle(20, 60_000)
async verifyResetCode(@Body() dto: VerifyResetCodeDto) {
return this.authService.verifyResetCode(dto);
}
@HttpCode(HttpStatus.OK)
@Post('reset-password')
@Throttle(10, 60_000)
async resetPassword(@Body() dto: ResetPasswordDto) {
return this.authService.resetPassword(dto);
}
@HttpCode(HttpStatus.OK)
@Post('send-email-verification')
@Throttle(8, 60_000)
async sendEmailVerification(@Body() dto: SendEmailVerificationDto) {
return this.authService.sendEmailVerification(dto);
}
@HttpCode(HttpStatus.OK)
@Post('verify-email')
@Throttle(20, 60_000)
async verifyEmail(@Body() dto: VerifyEmailDto) {
return this.authService.verifyEmail(dto);
}
@HttpCode(HttpStatus.OK)
@Post('superadmin/login')
@Throttle(10, 60_000)
async superAdminLogin(@Body() dto: SuperAdminLoginDto) {
return this.authService.superAdminLogin(dto);
}
@HttpCode(HttpStatus.OK)
@Post('superadmin/refresh')
@Throttle(20, 60_000)
async superAdminRefresh(@Body() dto: RefreshTokenDto) {
return this.authService.superAdminRefresh(dto);
}
@HttpCode(HttpStatus.OK)
@Post('superadmin/logout')
async superAdminLogout(@Body() dto: RefreshTokenDto): Promise<{ message: string }> {
await this.authService.superAdminLogout(dto);
return { message: 'Superadmin logged out successfully' };
}
@Get('google')
@UseGuards(GoogleAuthGuard)
async googleAuth(): Promise<void> {
return;
}
@Get('google/callback')
@UseGuards(GoogleAuthGuard)
async googleCallback(
@Req()
req: Request & {
user: { googleId: string; email: string; name: string; avatar?: string };
},
) {
return this.authService.loginWithGoogle(req.user);
}
@HttpCode(HttpStatus.OK)
@Post('google/token')
@Throttle(20, 60_000)
async googleTokenLogin(@Body() dto: GoogleTokenLoginDto) {
return this.authService.loginWithGoogleIdToken(dto);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('sessions')
async listMySessions(@CurrentUser() user: JwtPayload) {
return this.authService.listUserSessions(user.sub);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post('sessions/:jti/revoke')
async revokeSession(@CurrentUser() user: JwtPayload, @Param('jti') jti: string) {
await this.authService.revokeUserSession(user.sub, jti);
return { success: true };
}
}

عرض الملف

@@ -0,0 +1,73 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose';
import { EmailModule } from '../email/email.module';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthRepository } from './auth.repository';
import { AuthService } from './auth.service';
import { GoogleAuthGuard } from './guards/google-auth.guard';
import {
EmailVerificationCode,
EmailVerificationCodeSchema,
} from './schemas/email-verification-code.schema';
import {
PasswordResetCode,
PasswordResetCodeSchema,
} from './schemas/password-reset-code.schema';
import { RefreshToken, RefreshTokenSchema } from './schemas/refresh-token.schema';
import {
SuperAdminRefreshToken,
SuperAdminRefreshTokenSchema,
} from './schemas/super-admin-refresh-token.schema';
import { GoogleStrategy } from './strategies/google.strategy';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { SuperAdminJwtStrategy } from './strategies/super-admin-jwt.strategy';
@Module({
imports: [
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('jwt.accessSecret', { infer: true }),
signOptions: {
expiresIn: configService.get<string>('jwt.accessExpiresIn', { infer: true }),
},
}),
}),
MongooseModule.forFeature([
{
name: RefreshToken.name,
schema: RefreshTokenSchema,
},
{
name: SuperAdminRefreshToken.name,
schema: SuperAdminRefreshTokenSchema,
},
{
name: PasswordResetCode.name,
schema: PasswordResetCodeSchema,
},
{
name: EmailVerificationCode.name,
schema: EmailVerificationCodeSchema,
},
]),
EmailModule,
UsersModule,
],
controllers: [AuthController],
providers: [
AuthService,
AuthRepository,
JwtStrategy,
JwtRefreshStrategy,
GoogleStrategy,
GoogleAuthGuard,
SuperAdminJwtStrategy,
],
exports: [AuthService],
})
export class AuthModule {}

عرض الملف

@@ -0,0 +1,237 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import {
EmailVerificationCode,
EmailVerificationCodeDocument,
} from './schemas/email-verification-code.schema';
import {
PasswordResetCode,
PasswordResetCodeDocument,
} from './schemas/password-reset-code.schema';
import { RefreshToken, RefreshTokenDocument } from './schemas/refresh-token.schema';
import {
SuperAdminRefreshToken,
SuperAdminRefreshTokenDocument,
} from './schemas/super-admin-refresh-token.schema';
@Injectable()
export class AuthRepository {
constructor(
@InjectModel(RefreshToken.name)
private readonly refreshTokenModel: Model<RefreshTokenDocument>,
@InjectModel(SuperAdminRefreshToken.name)
private readonly superAdminRefreshTokenModel: Model<SuperAdminRefreshTokenDocument>,
@InjectModel(PasswordResetCode.name)
private readonly passwordResetCodeModel: Model<PasswordResetCodeDocument>,
@InjectModel(EmailVerificationCode.name)
private readonly emailVerificationCodeModel: Model<EmailVerificationCodeDocument>,
) {}
async createRefreshToken(userId: string, jti: string, tokenHash: string, expiresAt: Date): Promise<void> {
await this.refreshTokenModel.create({
userId: new Types.ObjectId(userId),
jti,
tokenHash,
expiresAt,
});
}
async findActiveUserTokens(userId: string): Promise<RefreshTokenDocument[]> {
return this.refreshTokenModel
.find({ userId: new Types.ObjectId(userId), revoked: false })
.select('+tokenHash')
.exec();
}
async revokeAllUserTokens(userId: string): Promise<void> {
await this.refreshTokenModel
.updateMany({ userId: new Types.ObjectId(userId), revoked: false }, { revoked: true })
.exec();
}
async revokeUserTokenByJti(userId: string, jti: string): Promise<void> {
await this.refreshTokenModel
.updateOne({ userId: new Types.ObjectId(userId), jti, revoked: false }, { revoked: true })
.exec();
}
async findActiveUserTokenByJti(userId: string, jti: string): Promise<RefreshTokenDocument | null> {
return this.refreshTokenModel
.findOne({ userId: new Types.ObjectId(userId), jti, revoked: false })
.select('+tokenHash')
.exec();
}
async markCompromisedAndRevokeAll(userId: string): Promise<void> {
await this.refreshTokenModel
.updateMany(
{ userId: new Types.ObjectId(userId), revoked: false },
{ revoked: true, compromised: true },
)
.exec();
}
async listUserSessions(userId: string): Promise<RefreshTokenDocument[]> {
return this.refreshTokenModel
.find({ userId: new Types.ObjectId(userId), revoked: false, expiresAt: { $gt: new Date() } })
.select('jti expiresAt createdAt')
.sort({ createdAt: -1 })
.exec();
}
async removeExpiredAndRevoked(userId: string): Promise<void> {
await this.refreshTokenModel
.deleteMany({
userId: new Types.ObjectId(userId),
$or: [{ revoked: true }, { expiresAt: { $lt: new Date() } }],
})
.exec();
}
async createSuperAdminRefreshToken(
adminEmail: string,
tokenHash: string,
expiresAt: Date,
): Promise<void> {
await this.superAdminRefreshTokenModel.create({
adminEmail: adminEmail.toLowerCase(),
tokenHash,
expiresAt,
});
}
async findActiveSuperAdminTokens(adminEmail: string): Promise<SuperAdminRefreshTokenDocument[]> {
return this.superAdminRefreshTokenModel
.find({ adminEmail: adminEmail.toLowerCase(), revoked: false })
.select('+tokenHash')
.exec();
}
async revokeAllSuperAdminTokens(adminEmail: string): Promise<void> {
await this.superAdminRefreshTokenModel
.updateMany({ adminEmail: adminEmail.toLowerCase(), revoked: false }, { revoked: true })
.exec();
}
async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> {
await this.superAdminRefreshTokenModel
.deleteMany({
adminEmail: adminEmail.toLowerCase(),
$or: [{ revoked: true }, { expiresAt: { $lt: new Date() } }],
})
.exec();
}
async invalidateActivePasswordResetCodes(userId: string): Promise<void> {
await this.passwordResetCodeModel
.updateMany(
{ userId: new Types.ObjectId(userId), used: false, expiresAt: { $gt: new Date() } },
{ used: true },
)
.exec();
}
async createPasswordResetCode(userId: string, codeHash: string, expiresAt: Date): Promise<void> {
await this.passwordResetCodeModel.create({
userId: new Types.ObjectId(userId),
codeHash,
expiresAt,
attempts: 0,
verified: false,
used: false,
});
}
async findLatestActivePasswordResetCode(userId: string): Promise<PasswordResetCodeDocument | null> {
return this.passwordResetCodeModel
.findOne({
userId: new Types.ObjectId(userId),
used: false,
expiresAt: { $gt: new Date() },
})
.select('+codeHash')
.sort({ createdAt: -1 })
.exec();
}
async incrementPasswordResetAttempts(id: string): Promise<void> {
await this.passwordResetCodeModel.findByIdAndUpdate(id, { $inc: { attempts: 1 } }).exec();
}
async markPasswordResetCodeVerified(id: string): Promise<void> {
await this.passwordResetCodeModel.findByIdAndUpdate(id, { verified: true }).exec();
}
async markPasswordResetCodeUsed(id: string): Promise<void> {
await this.passwordResetCodeModel.findByIdAndUpdate(id, { used: true }).exec();
}
async markPasswordResetCodeUsedByUser(userId: string): Promise<void> {
await this.passwordResetCodeModel
.updateMany({ userId: new Types.ObjectId(userId), used: false }, { used: true })
.exec();
}
async findValidVerifiedPasswordResetCode(
codeId: string,
userId: string,
): Promise<PasswordResetCodeDocument | null> {
return this.passwordResetCodeModel
.findOne({
_id: new Types.ObjectId(codeId),
userId: new Types.ObjectId(userId),
used: false,
verified: true,
expiresAt: { $gt: new Date() },
})
.exec();
}
async invalidateActiveEmailVerificationCodes(userId: string): Promise<void> {
await this.emailVerificationCodeModel
.updateMany(
{ userId: new Types.ObjectId(userId), used: false, expiresAt: { $gt: new Date() } },
{ used: true },
)
.exec();
}
async createEmailVerificationCode(userId: string, codeHash: string, expiresAt: Date): Promise<void> {
await this.emailVerificationCodeModel.create({
userId: new Types.ObjectId(userId),
codeHash,
expiresAt,
attempts: 0,
used: false,
});
}
async findLatestActiveEmailVerificationCode(
userId: string,
): Promise<EmailVerificationCodeDocument | null> {
return this.emailVerificationCodeModel
.findOne({
userId: new Types.ObjectId(userId),
used: false,
expiresAt: { $gt: new Date() },
})
.select('+codeHash')
.sort({ createdAt: -1 })
.exec();
}
async incrementEmailVerificationAttempts(id: string): Promise<void> {
await this.emailVerificationCodeModel.findByIdAndUpdate(id, { $inc: { attempts: 1 } }).exec();
}
async markEmailVerificationCodeUsed(id: string): Promise<void> {
await this.emailVerificationCodeModel.findByIdAndUpdate(id, { used: true }).exec();
}
async markAllEmailVerificationCodesUsedByUser(userId: string): Promise<void> {
await this.emailVerificationCodeModel
.updateMany({ userId: new Types.ObjectId(userId), used: false }, { used: true })
.exec();
}
}

عرض الملف

@@ -0,0 +1,666 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
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 { EmailService } from '../email/email.service';
import { UsersService } from '../users/users.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { GoogleTokenLoginDto } from './dto/google-token-login.dto';
import { LoginDto } from './dto/login.dto';
import { RegisterBasicDto } from './dto/register-basic.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterDto } from './dto/register.dto';
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 { AuthRepository } from './auth.repository';
import { AuthResult, TokenPair } from './types/token-pair.type';
@Injectable()
export class AuthService {
private readonly googleOAuthClient = new OAuth2Client();
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly usersService: UsersService,
private readonly authRepository: AuthRepository,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly emailService: EmailService,
) {}
async register(dto: RegisterDto): Promise<{ message: string; email: string; debugCode?: string }> {
if (dto.password !== dto.confirmPassword) {
throw new BadRequestException('Password confirmation does not match');
}
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const passwordHash = await hashValue(dto.password, saltRounds);
const generatedUsername = dto.username ?? (await this.generateUniqueUsernameFromEmail(dto.email));
const resolvedName = dto.name ?? dto.stageName ?? generatedUsername;
const { confirmPassword: _, ...registerPayload } = dto;
const user = await this.usersService.create({
...registerPayload,
name: resolvedName,
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.',
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 }> {
if (dto.password !== dto.confirmPassword) {
throw new BadRequestException('Password confirmation does not match');
}
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const passwordHash = await hashValue(dto.password, saltRounds);
const generatedUsername = await this.generateUniqueUsernameFromEmail(dto.email);
const user = await this.usersService.create({
name: generatedUsername,
username: generatedUsername,
email: dto.email,
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.',
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> {
const user = await this.usersService.findByEmailWithPassword(dto.email);
if (!user || !user.password) {
throw new UnauthorizedException('Invalid credentials');
}
if (user.isDisabled) {
throw new ForbiddenException('Account is disabled');
}
if (!user.isVerified) {
throw new ForbiddenException('Email not verified');
}
const isMatch = await compareHash(dto.password, user.password);
if (!isMatch) {
throw new UnauthorizedException('Invalid credentials');
}
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> };
}
async sendEmailVerification(
dto: SendEmailVerificationDto,
): 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';
if (!user || user.isDisabled) {
return { message };
}
if (user.isVerified) {
return { message: 'Email 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;
}
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');
return {
message: 'Email verified successfully',
...tokens,
user: safeUser.toObject() as unknown as Record<string, unknown>,
};
}
async refresh(dto: RefreshTokenDto): Promise<AuthResult> {
const decoded = this.jwtService.verify<{ sub: string; username: string; tokenType: string; jti?: string }>(
dto.refreshToken,
{
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
},
);
if (decoded.tokenType !== 'refresh' || !decoded.jti) {
throw new UnauthorizedException('Invalid refresh token');
}
const tokenRecord = await this.authRepository.findActiveUserTokenByJti(decoded.sub, decoded.jti);
if (!tokenRecord) {
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
throw new UnauthorizedException('Refresh token reuse detected');
}
const isMatch = await compareHash(dto.refreshToken, tokenRecord.tokenHash);
if (!isMatch) {
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
throw new UnauthorizedException('Refresh token reuse detected');
}
const safeUser = await this.usersService.findByIdOrFail(decoded.sub);
if (safeUser.isDisabled) {
throw new ForbiddenException('Account is disabled');
}
await this.authRepository.revokeUserTokenByJti(decoded.sub, decoded.jti);
const authTokens = await this.generateAndStoreTokenPair(
decoded.sub,
safeUser.username,
safeUser.role,
);
return { ...authTokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
}
async logout(dto: RefreshTokenDto): Promise<void> {
try {
const decoded = this.jwtService.verify<{ sub: string }>(dto.refreshToken, {
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
});
await this.authRepository.revokeAllUserTokens(decoded.sub);
await this.authRepository.removeExpiredAndRevoked(decoded.sub);
} catch {
throw new BadRequestException('Invalid refresh token');
}
}
async loginWithGoogle(googleUser: {
googleId: string;
email: string;
name: string;
avatar?: string;
}): Promise<AuthResult> {
let user = await this.usersService.findByGoogleId(googleUser.googleId);
if (!user) {
user = await this.usersService.findByEmail(googleUser.email);
}
if (!user) {
const generatedUsername = await this.generateUniqueUsernameFromEmail(googleUser.email);
const randomPassword = randomBytes(24).toString('hex');
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const passwordHash = await hashValue(randomPassword, saltRounds);
user = await this.usersService.create({
name: googleUser.name,
username: generatedUsername,
email: googleUser.email,
password: passwordHash,
avatar: googleUser.avatar ?? '',
isVerified: true,
});
}
if (!user.googleId) {
user = await this.usersService.linkGoogleAccount(user.id, googleUser.googleId, googleUser.avatar);
}
if (user.isDisabled) {
throw new ForbiddenException('Account is disabled');
}
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> };
}
async loginWithGoogleIdToken(dto: GoogleTokenLoginDto): Promise<AuthResult> {
const clientId = this.configService.get<string>('google.clientId', { infer: true });
if (!clientId) {
throw new BadRequestException('Google client is not configured');
}
let payload:
| {
sub?: string;
email?: string;
email_verified?: boolean;
name?: string;
picture?: string;
}
| undefined;
try {
const ticket = await this.googleOAuthClient.verifyIdToken({
idToken: dto.idToken,
audience: clientId,
});
payload = ticket.getPayload();
} catch {
throw new UnauthorizedException('Invalid Google id token');
}
if (!payload?.sub || !payload.email || payload.email_verified !== true) {
throw new UnauthorizedException('Google account data is invalid');
}
return this.loginWithGoogle({
googleId: payload.sub,
email: payload.email.toLowerCase(),
name: payload.name ?? payload.email.split('@')[0],
avatar: payload.picture,
});
}
async superAdminLogin(dto: SuperAdminLoginDto): Promise<{
accessToken: string;
refreshToken: string;
superAdmin: { email: string };
}> {
const configuredEmail = this.configService.get<string>('superAdmin.email', { infer: true });
const configuredPassword = this.configService.get<string>('superAdmin.password', { infer: true });
if (
!configuredEmail ||
!configuredPassword ||
dto.email.toLowerCase() !== configuredEmail.toLowerCase() ||
dto.password !== configuredPassword
) {
throw new UnauthorizedException('Invalid superadmin credentials');
}
const tokens = await this.generateAndStoreSuperAdminTokenPair(configuredEmail);
return { ...tokens, superAdmin: { email: configuredEmail } };
}
async superAdminRefresh(dto: RefreshTokenDto): Promise<{
accessToken: string;
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 }),
});
if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email) {
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');
}
let validTokenFound = false;
for (const token of activeTokens) {
const isMatch = await compareHash(dto.refreshToken, token.tokenHash);
if (isMatch) {
validTokenFound = true;
break;
}
}
if (!validTokenFound) {
throw new UnauthorizedException('Invalid superadmin refresh token');
}
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
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, {
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
});
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
await this.authRepository.removeExpiredAndRevokedSuperAdmin(decoded.email);
} catch {
throw new BadRequestException('Invalid superadmin refresh token');
}
}
async listUserSessions(userId: string): Promise<{ items: Array<{ jti: string; createdAt: Date; expiresAt: Date }> }> {
const sessions = await this.authRepository.listUserSessions(userId);
return {
items: sessions.map((s) => ({
jti: s.jti,
createdAt: (s as unknown as { createdAt: Date }).createdAt,
expiresAt: s.expiresAt,
})),
};
}
async revokeUserSession(userId: string, jti: string): Promise<void> {
await this.authRepository.revokeUserTokenByJti(userId, jti);
}
async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string; debugCode?: string }> {
const normalizedEmail = dto.email.toLowerCase();
const user = await this.usersService.findByEmail(normalizedEmail);
const message = 'If this email exists, a reset code was sent';
if (!user || user.isDisabled) {
return { message };
}
const code = this.generateResetCode();
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const codeHash = await hashValue(code, saltRounds);
const expiresMinutes = this.configService.get<number>('passwordReset.codeExpiresMinutes', {
infer: true,
});
const expiresAt = new Date(Date.now() + expiresMinutes * 60 * 1000);
await this.authRepository.invalidateActivePasswordResetCodes(user.id);
await this.authRepository.createPasswordResetCode(user.id, codeHash, expiresAt);
await this.emailService.sendPasswordResetCode(normalizedEmail, code, expiresMinutes);
this.logger.log(`Password reset code generated for ${normalizedEmail}`);
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
if (nodeEnv !== 'production') {
return { message, debugCode: code };
}
return { message };
}
async verifyResetCode(
dto: VerifyResetCodeDto,
): Promise<{ resetToken: string; expiresIn: string }> {
const normalizedEmail = dto.email.toLowerCase();
const user = await this.usersService.findByEmail(normalizedEmail);
if (!user || user.isDisabled) {
throw new UnauthorizedException('Invalid or expired reset code');
}
const codeRecord = await this.authRepository.findLatestActivePasswordResetCode(user.id);
if (!codeRecord) {
throw new UnauthorizedException('Invalid or expired reset code');
}
const maxAttempts = this.configService.get<number>('passwordReset.maxAttempts', { infer: true });
if (codeRecord.attempts >= maxAttempts) {
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
throw new UnauthorizedException('Reset code attempts exceeded');
}
const isMatch = await compareHash(dto.code, codeRecord.codeHash);
if (!isMatch) {
await this.authRepository.incrementPasswordResetAttempts(codeRecord.id);
const attemptsAfter = codeRecord.attempts + 1;
if (attemptsAfter >= maxAttempts) {
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
}
throw new UnauthorizedException('Invalid or expired reset code');
}
await this.authRepository.markPasswordResetCodeVerified(codeRecord.id);
const resetTokenExpiresIn =
this.configService.get<string>('passwordReset.tokenExpiresIn', {
infer: true,
}) ?? '15m';
const resetToken = await this.jwtService.signAsync(
{ sub: user.id, tokenType: 'password_reset', prcId: codeRecord.id },
{
secret: this.configService.get<string>('passwordReset.tokenSecret', { infer: true }),
expiresIn: resetTokenExpiresIn,
},
);
return {
resetToken,
expiresIn: resetTokenExpiresIn,
};
}
async resetPassword(dto: ResetPasswordDto): Promise<{ message: string }> {
if (dto.newPassword !== dto.confirmPassword) {
throw new BadRequestException('Password confirmation does not match');
}
let decoded: { sub: string; tokenType: string; prcId: string };
try {
decoded = this.jwtService.verify(dto.resetToken, {
secret: this.configService.get<string>('passwordReset.tokenSecret', { infer: true }),
});
} catch {
throw new UnauthorizedException('Invalid or expired reset token');
}
if (decoded.tokenType !== 'password_reset' || !decoded.prcId || !decoded.sub) {
throw new UnauthorizedException('Invalid or expired reset token');
}
const codeRecord = await this.authRepository.findValidVerifiedPasswordResetCode(
decoded.prcId,
decoded.sub,
);
if (!codeRecord) {
throw new UnauthorizedException('Invalid or expired reset token');
}
const user = await this.usersService.findByIdOrFail(decoded.sub);
if (user.isDisabled) {
throw new ForbiddenException('Account is disabled');
}
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const passwordHash = await hashValue(dto.newPassword, saltRounds);
await this.usersService.updatePassword(decoded.sub, passwordHash);
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
await this.authRepository.markPasswordResetCodeUsedByUser(decoded.sub);
await this.authRepository.revokeAllUserTokens(decoded.sub);
await this.authRepository.removeExpiredAndRevoked(decoded.sub);
return { message: 'Password reset successfully' };
}
private async generateAndStoreTokenPair(
userId: string,
username: string,
role: string,
): Promise<TokenPair> {
const refreshJti = randomUUID();
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{ sub: userId, username, role, tokenType: 'access' },
{
secret: this.configService.get<string>('jwt.accessSecret', { infer: true }),
expiresIn: this.configService.get<string>('jwt.accessExpiresIn', { infer: true }),
},
),
this.jwtService.signAsync(
{ sub: userId, username, role, tokenType: 'refresh', jti: refreshJti },
{
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
expiresIn: this.configService.get<string>('jwt.refreshExpiresIn', { infer: true }),
},
),
]);
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const tokenHash = await hashValue(refreshToken, saltRounds);
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn', {
infer: true,
});
const refreshExpiresInMs = this.parseExpiresInToMs(refreshExpiresIn ?? '30d');
await this.authRepository.createRefreshToken(userId, refreshJti, tokenHash, new Date(Date.now() + refreshExpiresInMs));
return { accessToken, refreshToken };
}
private async generateAndStoreSuperAdminTokenPair(adminEmail: string): Promise<TokenPair> {
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{
sub: 'superadmin',
username: 'superadmin',
email: adminEmail.toLowerCase(),
role: 'superadmin',
tokenType: 'superadmin_access',
},
{
secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }),
expiresIn: this.configService.get<string>('superAdmin.accessExpiresIn', { infer: true }),
},
),
this.jwtService.signAsync(
{
sub: 'superadmin',
username: 'superadmin',
email: adminEmail.toLowerCase(),
role: 'superadmin',
tokenType: 'superadmin_refresh',
},
{
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
expiresIn: this.configService.get<string>('superAdmin.refreshExpiresIn', { infer: true }),
},
),
]);
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const tokenHash = await hashValue(refreshToken, saltRounds);
const refreshExpiresIn = this.configService.get<string>('superAdmin.refreshExpiresIn', {
infer: true,
});
const refreshExpiresInMs = this.parseExpiresInToMs(refreshExpiresIn ?? '30d');
await this.authRepository.createSuperAdminRefreshToken(
adminEmail,
tokenHash,
new Date(Date.now() + refreshExpiresInMs),
);
return { accessToken, refreshToken };
}
private parseExpiresInToMs(expiresIn: string): number {
const regex = /^(\d+)([smhd])$/;
const match = expiresIn.match(regex);
if (!match) {
return 30 * 24 * 60 * 60 * 1000;
}
const value = Number(match[1]);
const unit = match[2];
const multipliers: Record<string, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return value * multipliers[unit];
}
private async generateUniqueUsernameFromEmail(email: string): Promise<string> {
const base = email.split('@')[0].replace(/[^a-zA-Z0-9_.]/g, '').toLowerCase() || 'user';
let candidate = base.slice(0, 24);
let counter = 1;
while (await this.usersService.findByUsername(candidate)) {
const suffix = `_${counter}`;
const maxBaseLength = 30 - suffix.length;
candidate = `${base.slice(0, Math.max(1, maxBaseLength))}${suffix}`;
counter += 1;
}
return candidate;
}
private generateResetCode(): string {
return String(randomInt(100000, 1000000));
}
private async issueEmailVerificationCode(userId: string, email: string): Promise<string> {
const code = this.generateResetCode();
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const codeHash = await hashValue(code, saltRounds);
const expiresMinutes = this.configService.get<number>('emailVerification.codeExpiresMinutes', {
infer: true,
});
const expiresAt = new Date(Date.now() + expiresMinutes * 60 * 1000);
await this.authRepository.invalidateActiveEmailVerificationCodes(userId);
await this.authRepository.createEmailVerificationCode(userId, codeHash, expiresAt);
await this.emailService.sendVerificationCode(email, code, expiresMinutes);
this.logger.log(`Email verification code generated for ${email}`);
return code;
}
}

عرض الملف

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
export class AuthResponseDto {
@ApiProperty()
accessToken!: string;
@ApiProperty()
refreshToken!: string;
@ApiProperty({
description: 'Full user profile without password',
type: 'object',
additionalProperties: true,
})
user!: Record<string, unknown>;
}

عرض الملف

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
export class ForgotPasswordDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email!: string;
}

عرض الملف

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';
export class GoogleTokenLoginDto {
@ApiProperty({ description: 'Google ID token from frontend Google Sign-In' })
@IsString()
@MinLength(20)
idToken!: string;
}

عرض الملف

@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, Length } from 'class-validator';
export class LoginDto {
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
email!: string;
@ApiProperty({ minLength: 8 })
@IsString()
@Length(8, 64)
password!: string;
}

عرض الملف

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';
export class RefreshTokenDto {
@ApiProperty()
@IsString()
@MinLength(20)
refreshToken!: string;
}

عرض الملف

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, Length } from 'class-validator';
export class RegisterBasicDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email!: string;
@ApiProperty({ minLength: 8, example: 'StrongPass123!' })
@IsString()
@Length(8, 64)
password!: string;
@ApiProperty({ minLength: 8, example: 'StrongPass123!' })
@IsString()
@Length(8, 64)
confirmPassword!: string;
}

عرض الملف

@@ -0,0 +1,112 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEmail,
IsEnum,
IsNumber,
IsOptional,
IsString,
Length,
Max,
Matches,
Min,
} from 'class-validator';
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
import { MusicRole } from '../../../common/enums/music-role.enum';
export class RegisterDto {
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
email!: string;
@ApiProperty({ minLength: 8 })
@IsString()
@Length(8, 64)
password!: string;
@ApiProperty({ minLength: 8 })
@IsString()
@Length(8, 64)
confirmPassword!: string;
@ApiProperty({ required: false, example: 'John Doe' })
@IsOptional()
@IsString()
@Length(2, 80)
name?: string;
@ApiProperty({ required: false, example: 'john_doe' })
@IsOptional()
@IsString()
@Length(3, 30)
@Matches(/^[a-zA-Z0-9_.]+$/, { message: 'username can contain letters, numbers, _ and .' })
username?: string;
@ApiProperty({ required: false, example: 'Artist One' })
@IsOptional()
@IsString()
@Length(0, 80)
stageName?: string;
@ApiProperty({ required: false, maxLength: 160 })
@IsOptional()
@IsString()
@Length(0, 160)
bio?: string;
@ApiProperty({ required: false, example: 'Riyadh, Saudi Arabia' })
@IsOptional()
@IsString()
@Length(0, 120)
location?: string;
@ApiProperty({ example: 24.7136, minimum: -90, maximum: 90 })
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
latitude!: number;
@ApiProperty({ example: 46.6753, minimum: -180, maximum: 180 })
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
longitude!: number;
@ApiProperty({ required: false, default: false })
@IsOptional()
@IsBoolean()
isPrivate?: boolean;
@ApiProperty({ required: false, enum: MusicRole, isArray: true })
@IsOptional()
@IsArray()
@IsEnum(MusicRole, { each: true })
musicRoles?: MusicRole[];
@ApiProperty({ required: false, enum: ExperienceLevel, example: ExperienceLevel.BEGINNER })
@IsOptional()
@IsEnum(ExperienceLevel)
experienceLevel?: ExperienceLevel;
@ApiProperty({ required: false, type: [String], example: ['Tarab', 'Pop'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
musicGenres?: string[];
@ApiProperty({ required: false, type: [String], example: ['Oud', 'Piano'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
favoriteInstruments?: string[];
@ApiProperty({ required: false, type: [String], example: ['Bayati', 'Rast'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
favoriteMaqamat?: string[];
}

عرض الملف

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Length } from 'class-validator';
export class ResetPasswordDto {
@ApiProperty()
@IsString()
resetToken!: string;
@ApiProperty({ minLength: 8, example: 'NewStrongPass123!' })
@IsString()
@Length(8, 64)
newPassword!: string;
@ApiProperty({ minLength: 8, example: 'NewStrongPass123!' })
@IsString()
@Length(8, 64)
confirmPassword!: string;
}

عرض الملف

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
export class SendEmailVerificationDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email!: string;
}

عرض الملف

@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, Length } from 'class-validator';
export class SuperAdminLoginDto {
@ApiProperty({ example: 'admin@oudelaa.com' })
@IsEmail()
email!: string;
@ApiProperty({ example: 'SuperAdminStrongPass123!' })
@IsString()
@Length(8, 128)
password!: string;
}

عرض الملف

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, Length, Matches } from 'class-validator';
export class VerifyEmailDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email!: string;
@ApiProperty({ example: '123456' })
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/, { message: 'code must be 6 digits' })
code!: string;
}

عرض الملف

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, Length, Matches } from 'class-validator';
export class VerifyResetCodeDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email!: string;
@ApiProperty({ example: '123456' })
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/, { message: 'code must be 6 digits' })
code!: string;
}

عرض الملف

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {}

عرض الملف

@@ -0,0 +1,28 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
export type EmailVerificationCodeDocument = HydratedDocument<EmailVerificationCode>;
@Schema({ timestamps: true, versionKey: false })
export class EmailVerificationCode {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
userId!: Types.ObjectId;
@Prop({ required: true, select: false })
codeHash!: string;
@Prop({ required: true, index: true })
expiresAt!: Date;
@Prop({ default: 0, min: 0 })
attempts!: number;
@Prop({ default: false, index: true })
used!: boolean;
}
export const EmailVerificationCodeSchema = SchemaFactory.createForClass(EmailVerificationCode);
EmailVerificationCodeSchema.index({ userId: 1, used: 1, expiresAt: -1 });
EmailVerificationCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

عرض الملف

@@ -0,0 +1,31 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
export type PasswordResetCodeDocument = HydratedDocument<PasswordResetCode>;
@Schema({ timestamps: true, versionKey: false })
export class PasswordResetCode {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
userId!: Types.ObjectId;
@Prop({ required: true, select: false })
codeHash!: string;
@Prop({ required: true, index: true })
expiresAt!: Date;
@Prop({ default: 0, min: 0 })
attempts!: number;
@Prop({ default: false, index: true })
verified!: boolean;
@Prop({ default: false, index: true })
used!: boolean;
}
export const PasswordResetCodeSchema = SchemaFactory.createForClass(PasswordResetCode);
PasswordResetCodeSchema.index({ userId: 1, used: 1, expiresAt: -1 });
PasswordResetCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

عرض الملف

@@ -0,0 +1,32 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
export type RefreshTokenDocument = HydratedDocument<RefreshToken>;
@Schema({ timestamps: true, versionKey: false })
export class RefreshToken {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
userId!: Types.ObjectId;
@Prop({ required: true, select: false })
tokenHash!: string;
@Prop({ required: true, index: true, unique: true })
jti!: string;
@Prop({ required: true })
expiresAt!: Date;
@Prop({ default: false })
revoked!: boolean;
@Prop({ default: false, index: true })
compromised!: boolean;
}
export const RefreshTokenSchema = SchemaFactory.createForClass(RefreshToken);
RefreshTokenSchema.index({ userId: 1, revoked: 1 });
RefreshTokenSchema.index({ userId: 1, jti: 1 });
RefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

عرض الملف

@@ -0,0 +1,23 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type SuperAdminRefreshTokenDocument = HydratedDocument<SuperAdminRefreshToken>;
@Schema({ timestamps: true, versionKey: false })
export class SuperAdminRefreshToken {
@Prop({ required: true, trim: true, lowercase: true, index: true })
adminEmail!: string;
@Prop({ required: true, select: false })
tokenHash!: string;
@Prop({ required: true })
expiresAt!: Date;
@Prop({ default: false })
revoked!: boolean;
}
export const SuperAdminRefreshTokenSchema = SchemaFactory.createForClass(SuperAdminRefreshToken);
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, revoked: 1 });
SuperAdminRefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

عرض الملف

@@ -0,0 +1,42 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(configService: ConfigService) {
super({
clientID:
configService.get<string>('google.clientId', { infer: true }) || 'missing-google-client-id',
clientSecret:
configService.get<string>('google.clientSecret', { infer: true }) ||
'missing-google-client-secret',
callbackURL:
configService.get<string>('google.callbackUrl', { infer: true }) ||
'http://localhost:4000/api/v1/auth/google/callback',
scope: ['email', 'profile'],
});
}
validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
done: VerifyCallback,
): void {
const email = profile.emails?.[0]?.value?.toLowerCase();
if (!email) {
done(new UnauthorizedException('Google account email is not available'));
return;
}
done(null, {
googleId: profile.id,
email,
name: profile.displayName ?? 'Google User',
avatar: profile.photos?.[0]?.value,
});
}
}

عرض الملف

@@ -0,0 +1,26 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.refreshSecret', { infer: true }),
passReqToCallback: true,
});
}
validate(_: Request, payload: JwtPayload): JwtPayload {
if (payload.tokenType !== 'refresh') {
throw new UnauthorizedException('Invalid token type');
}
return payload;
}
}

عرض الملف

@@ -0,0 +1,24 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.accessSecret', { infer: true }),
});
}
validate(payload: JwtPayload): JwtPayload {
if (payload.tokenType !== 'access') {
throw new UnauthorizedException('Invalid token type');
}
return payload;
}
}

عرض الملف

@@ -0,0 +1,24 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
@Injectable()
export class SuperAdminJwtStrategy extends PassportStrategy(Strategy, 'superadmin-jwt') {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('superAdmin.accessSecret', { infer: true }),
});
}
validate(payload: JwtPayload): JwtPayload {
if (payload.tokenType !== 'superadmin_access') {
throw new UnauthorizedException('Invalid superadmin token');
}
return payload;
}
}

عرض الملف

@@ -0,0 +1,8 @@
export type TokenPair = {
accessToken: string;
refreshToken: string;
};
export type AuthResult = TokenPair & {
user: Record<string, unknown>;
};

عرض الملف

@@ -0,0 +1,78 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Throttle } from '../../common/decorators/throttle.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { ChatService } from './chat.service';
import { CreateConversationDto } from './dto/create-conversation.dto';
import { MessageQueryDto } from './dto/message-query.dto';
import { SendMessageDto } from './dto/send-message.dto';
@ApiTags('Chat')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('chat')
export class ChatController {
constructor(private readonly chatService: ChatService) {}
@Post('conversations')
@Throttle(40, 60_000)
async createConversation(@CurrentUser() user: JwtPayload, @Body() dto: CreateConversationDto) {
return this.chatService.createConversation(user.sub, dto);
}
@Get('conversations')
async myConversations(@CurrentUser() user: JwtPayload, @Query() query: MessageQueryDto) {
return this.chatService.getMyConversations(user.sub, query);
}
@Get('conversations/:conversationId/messages')
async messages(
@CurrentUser() user: JwtPayload,
@Param('conversationId') conversationId: string,
@Query() query: MessageQueryDto,
) {
return this.chatService.getMessages(user.sub, conversationId, query);
}
@Post('messages')
@Throttle(120, 60_000)
async sendMessage(@CurrentUser() user: JwtPayload, @Body() dto: SendMessageDto) {
return this.chatService.sendMessage(user.sub, dto);
}
@Patch('messages/:messageId/seen')
@Throttle(200, 60_000)
async markSeen(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
return this.chatService.markMessageSeen(user.sub, messageId);
}
@Patch('messages/:messageId/unsend')
@Throttle(80, 60_000)
async unsend(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
return this.chatService.unsendMessage(user.sub, messageId);
}
@Post('blocks/:targetUserId')
@Throttle(20, 60_000)
async blockUser(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
return this.chatService.blockUser(user.sub, targetUserId);
}
@Patch('blocks/:targetUserId/unblock')
@Throttle(20, 60_000)
async unblockUser(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
return this.chatService.unblockUser(user.sub, targetUserId);
}
@Get('blocks/status/:targetUserId')
async blockStatus(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
return this.chatService.getBlockStatus(user.sub, targetUserId);
}
@Get('blocks')
async myBlocks(@CurrentUser() user: JwtPayload) {
return this.chatService.getMyBlockedUsers(user.sub);
}
}

عرض الملف

@@ -0,0 +1,137 @@
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { ChatService } from './chat.service';
import { SendMessageDto } from './dto/send-message.dto';
type SocketWithUser = Socket & { data: { userId?: string } };
@WebSocketGateway({ cors: { origin: '*' }, namespace: 'chat' })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
constructor(
private readonly chatService: ChatService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async handleConnection(client: SocketWithUser) {
const token = this.extractToken(client);
if (!token) {
client.disconnect(true);
return;
}
try {
const payload = this.jwtService.verify<{ sub: string; tokenType: string }>(token, {
secret: this.configService.get<string>('jwt.accessSecret', { infer: true }),
});
if (payload.tokenType !== 'access') {
client.disconnect(true);
return;
}
client.data.userId = payload.sub;
await client.join(this.userRoom(payload.sub));
this.server.to(this.userRoom(payload.sub)).emit('presence', { userId: payload.sub, online: true });
} catch {
client.disconnect(true);
}
}
handleDisconnect(client: SocketWithUser) {
const userId = client.data.userId;
if (userId) {
this.server.to(this.userRoom(userId)).emit('presence', { userId, online: false });
}
}
@SubscribeMessage('join_conversation')
async joinConversation(
@ConnectedSocket() client: SocketWithUser,
@MessageBody() body: { conversationId: string },
) {
const userId = client.data.userId;
if (!userId) return;
const conversation = await this.chatService.assertConversationMember(userId, body.conversationId);
await client.join(this.conversationRoom(conversation.id));
client.emit('joined_conversation', { conversationId: conversation.id });
}
@SubscribeMessage('send_message')
async sendMessage(
@ConnectedSocket() client: SocketWithUser,
@MessageBody() dto: SendMessageDto,
) {
const userId = client.data.userId;
if (!userId) return;
const message = await this.chatService.sendMessage(userId, dto);
this.server.to(this.conversationRoom(message.conversationId.toString())).emit('new_message', message);
return message;
}
@SubscribeMessage('typing')
async typing(
@ConnectedSocket() client: SocketWithUser,
@MessageBody() body: { conversationId: string; isTyping: boolean },
) {
const userId = client.data.userId;
if (!userId) return;
await this.chatService.assertConversationMember(userId, body.conversationId);
client.to(this.conversationRoom(body.conversationId)).emit('typing', {
conversationId: body.conversationId,
userId,
isTyping: !!body.isTyping,
});
}
@SubscribeMessage('mark_seen')
async markSeen(
@ConnectedSocket() client: SocketWithUser,
@MessageBody() body: { messageId: string; conversationId: string },
) {
const userId = client.data.userId;
if (!userId) return;
await this.chatService.markMessageSeen(userId, body.messageId);
this.server.to(this.conversationRoom(body.conversationId)).emit('message_seen', {
messageId: body.messageId,
userId,
});
}
private extractToken(client: Socket): string | null {
const authToken = client.handshake.auth?.token;
if (typeof authToken === 'string' && authToken.trim()) {
return authToken.replace(/^Bearer\s+/i, '').trim();
}
const headerAuth = client.handshake.headers.authorization;
if (typeof headerAuth === 'string' && headerAuth.trim()) {
return headerAuth.replace(/^Bearer\s+/i, '').trim();
}
return null;
}
private userRoom(userId: string): string {
return `user:${userId}`;
}
private conversationRoom(conversationId: string): string {
return `conversation:${conversationId}`;
}
}

عرض الملف

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose';
import { UsersModule } from '../users/users.module';
import { ChatController } from './chat.controller';
import { ChatGateway } from './chat.gateway';
import { ChatService } from './chat.service';
import { ChatRepository } from './chat.repository';
import { ChatBlock, ChatBlockSchema } from './schemas/chat-block.schema';
import { Conversation, ConversationSchema } from './schemas/conversation.schema';
import { Message, MessageSchema } from './schemas/message.schema';
@Module({
imports: [
ConfigModule,
JwtModule.register({}),
UsersModule,
MongooseModule.forFeature([
{ name: Conversation.name, schema: ConversationSchema },
{ name: Message.name, schema: MessageSchema },
{ name: ChatBlock.name, schema: ChatBlockSchema },
]),
],
controllers: [ChatController],
providers: [ChatService, ChatRepository, ChatGateway],
exports: [ChatService],
})
export class ChatModule {}

عرض الملف

@@ -0,0 +1,221 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model, Types } from 'mongoose';
import { ChatBlock, ChatBlockDocument } from './schemas/chat-block.schema';
import { Conversation, ConversationDocument } from './schemas/conversation.schema';
import { Message, MessageDocument } from './schemas/message.schema';
@Injectable()
export class ChatRepository {
constructor(
@InjectModel(Conversation.name) private readonly conversationModel: Model<ConversationDocument>,
@InjectModel(Message.name) private readonly messageModel: Model<MessageDocument>,
@InjectModel(ChatBlock.name) private readonly chatBlockModel: Model<ChatBlockDocument>,
) {}
async findConversationById(id: string): Promise<ConversationDocument | null> {
return this.conversationModel.findById(id).exec();
}
async findDirectConversation(userAId: string, userBId: string): Promise<ConversationDocument | null> {
return this.conversationModel
.findOne({
isGroup: false,
participantIds: {
$all: [new Types.ObjectId(userAId), new Types.ObjectId(userBId)],
$size: 2,
},
})
.exec();
}
async createConversation(payload: {
participantIds: string[];
isGroup: boolean;
title?: string;
createdBy: string;
}): Promise<ConversationDocument> {
const participantIds = payload.participantIds.map((id) => new Types.ObjectId(id));
const unreadCountByUser: Record<string, number> = {};
payload.participantIds.forEach((id) => {
unreadCountByUser[id] = 0;
});
return this.conversationModel.create({
participantIds,
isGroup: payload.isGroup,
title: payload.title ?? '',
createdBy: new Types.ObjectId(payload.createdBy),
unreadCountByUser,
lastMessageText: '',
});
}
async findConversationsForUser(userId: string, skip: number, limit: number): 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 })
.skip(skip)
.limit(limit)
.exec();
}
async countConversationsForUser(userId: string): Promise<number> {
return this.conversationModel.countDocuments({ participantIds: new Types.ObjectId(userId) }).exec();
}
async createMessage(payload: {
conversationId: string;
senderId: string;
content?: string;
messageType: 'text' | 'image' | 'video' | 'audio';
mediaUrl?: string;
}): Promise<MessageDocument> {
return this.messageModel.create({
conversationId: new Types.ObjectId(payload.conversationId),
senderId: new Types.ObjectId(payload.senderId),
content: payload.content ?? '',
messageType: payload.messageType,
mediaUrl: payload.mediaUrl ?? '',
seenBy: [new Types.ObjectId(payload.senderId)],
isUnsent: false,
});
}
async findMessages(conversationId: string, skip: number, limit: number): Promise<MessageDocument[]> {
return this.messageModel
.find({ conversationId: new Types.ObjectId(conversationId) })
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.exec();
}
async countMessages(conversationId: string): Promise<number> {
return this.messageModel.countDocuments({ conversationId: new Types.ObjectId(conversationId) }).exec();
}
async findMessageById(messageId: string): Promise<MessageDocument | null> {
return this.messageModel.findById(messageId).exec();
}
async markMessageSeen(messageId: string, userId: string): Promise<void> {
await this.messageModel
.findByIdAndUpdate(messageId, { $addToSet: { seenBy: new Types.ObjectId(userId) } }, { new: false })
.exec();
}
async updateConversationAfterNewMessage(
conversationId: string,
messageId: string,
senderId: string,
messageText: string,
): Promise<ConversationDocument | null> {
const conversation = await this.conversationModel.findById(conversationId).exec();
if (!conversation) {
return null;
}
const unreadMap = new Map<string, number>(
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
);
for (const participantId of conversation.participantIds) {
const id = participantId.toString();
if (id === senderId) {
unreadMap.set(id, 0);
} else {
unreadMap.set(id, (unreadMap.get(id) ?? 0) + 1);
}
}
conversation.lastMessageId = new Types.ObjectId(messageId);
conversation.lastMessageText = messageText.slice(0, 4000);
conversation.lastMessageAt = new Date();
conversation.unreadCountByUser = unreadMap as unknown as Map<string, number>;
await conversation.save();
return conversation;
}
async clearConversationUnreadForUser(conversationId: string, userId: string): Promise<void> {
const conversation = await this.conversationModel.findById(conversationId).exec();
if (!conversation) {
return;
}
const unreadMap = new Map<string, number>(
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
);
unreadMap.set(userId, 0);
conversation.unreadCountByUser = unreadMap as unknown as Map<string, number>;
await conversation.save();
}
async unsendMessage(messageId: string, senderId: string): Promise<MessageDocument | null> {
return this.messageModel
.findOneAndUpdate(
{ _id: new Types.ObjectId(messageId), senderId: new Types.ObjectId(senderId) },
{
isUnsent: true,
content: '',
mediaUrl: '',
messageType: 'text',
},
{ new: true },
)
.exec();
}
async findManyMessages(filter: FilterQuery<MessageDocument>): Promise<MessageDocument[]> {
return this.messageModel.find(filter).exec();
}
async createBlock(blockerId: string, blockedId: string): Promise<void> {
await this.chatBlockModel
.updateOne(
{ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) },
{
$setOnInsert: {
blockerId: new Types.ObjectId(blockerId),
blockedId: new Types.ObjectId(blockedId),
},
},
{ upsert: true },
)
.exec();
}
async removeBlock(blockerId: string, blockedId: string): Promise<void> {
await this.chatBlockModel
.deleteOne({ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) })
.exec();
}
async findBlock(blockerId: string, blockedId: string): Promise<ChatBlockDocument | null> {
return this.chatBlockModel
.findOne({ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) })
.exec();
}
async findAnyBlockBetween(userAId: string, userBId: string): Promise<ChatBlockDocument | null> {
return this.chatBlockModel
.findOne({
$or: [
{ blockerId: new Types.ObjectId(userAId), blockedId: new Types.ObjectId(userBId) },
{ blockerId: new Types.ObjectId(userBId), blockedId: new Types.ObjectId(userAId) },
],
})
.exec();
}
async findBlocksByBlocker(blockerId: string): Promise<ChatBlockDocument[]> {
return this.chatBlockModel
.find({ blockerId: new Types.ObjectId(blockerId) })
.populate({ path: 'blockedId', select: 'name username stageName avatar isVerified isDisabled' })
.sort({ createdAt: -1 })
.exec();
}
}

عرض الملف

@@ -0,0 +1,250 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
import { UsersRepository } from '../users/users.repository';
import { CreateConversationDto } from './dto/create-conversation.dto';
import { MessageQueryDto } from './dto/message-query.dto';
import { SendMessageDto } from './dto/send-message.dto';
import { ChatRepository } from './chat.repository';
@Injectable()
export class ChatService {
constructor(
private readonly chatRepository: ChatRepository,
private readonly usersRepository: UsersRepository,
) {}
async createConversation(currentUserId: string, dto: CreateConversationDto) {
const uniqueParticipantIds = Array.from(new Set([currentUserId, ...dto.participantIds]));
if (uniqueParticipantIds.length < 2) {
throw new BadRequestException('Conversation must include at least 2 participants');
}
for (const participantId of uniqueParticipantIds) {
if (!Types.ObjectId.isValid(participantId)) {
throw new BadRequestException('Invalid participant id');
}
}
const users = await Promise.all(uniqueParticipantIds.map((id) => this.usersRepository.findById(id)));
if (users.some((u) => !u || u.isDisabled)) {
throw new BadRequestException('One or more participants are invalid or disabled');
}
const isGroup = dto.isGroup ?? uniqueParticipantIds.length > 2;
if (!isGroup && uniqueParticipantIds.length !== 2) {
throw new BadRequestException('Direct conversation must contain exactly 2 participants');
}
if (!isGroup) {
const otherId = uniqueParticipantIds.find((id) => id !== currentUserId) as string;
const block = await this.chatRepository.findAnyBlockBetween(currentUserId, otherId);
if (block) {
throw new ForbiddenException('You cannot start chat with this user');
}
const existing = await this.chatRepository.findDirectConversation(currentUserId, otherId);
if (existing) {
return existing;
}
}
return this.chatRepository.createConversation({
participantIds: uniqueParticipantIds,
isGroup,
title: dto.title,
createdBy: currentUserId,
});
}
async getMyConversations(currentUserId: string, query: MessageQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor);
const skip = cursorOffset ?? (page - 1) * limit;
const [items, total] = await Promise.all([
this.chatRepository.findConversationsForUser(currentUserId, skip, limit),
this.chatRepository.countConversationsForUser(currentUserId),
]);
const mappedItems = items.map((conversation) => {
const unreadMap = (conversation.unreadCountByUser as unknown as Record<string, number>) ?? {};
return {
...conversation.toObject(),
unreadCount: unreadMap[currentUserId] ?? 0,
lastMessageAt: conversation.lastMessageAt ?? null,
};
});
const nextOffset = skip + mappedItems.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return {
items: mappedItems,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
nextCursor,
};
}
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
const conversation = await this.assertConversationMember(currentUserId, conversationId);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor);
const skip = cursorOffset ?? (page - 1) * limit;
const [items, total] = await Promise.all([
this.chatRepository.findMessages(conversation.id, skip, limit),
this.chatRepository.countMessages(conversation.id),
]);
await this.chatRepository.clearConversationUnreadForUser(conversation.id, currentUserId);
const nextOffset = skip + items.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return {
items,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
nextCursor,
};
}
async sendMessage(currentUserId: string, dto: SendMessageDto) {
const conversation = await this.assertConversationMember(currentUserId, dto.conversationId);
await this.assertNoChatBlockInConversation(currentUserId, conversation.participantIds.map((id) => id.toString()));
const messageType = dto.messageType ?? 'text';
const content = dto.content?.trim() ?? '';
const mediaUrl = dto.mediaUrl?.trim() ?? '';
if (messageType === 'text' && !content) {
throw new BadRequestException('Text message content is required');
}
if (messageType !== 'text' && !mediaUrl) {
throw new BadRequestException('mediaUrl is required for non-text messages');
}
const message = await this.chatRepository.createMessage({
conversationId: conversation.id,
senderId: currentUserId,
content,
messageType,
mediaUrl,
});
const preview = messageType === 'text' ? content : `${messageType} message`;
await this.chatRepository.updateConversationAfterNewMessage(
conversation.id,
message.id,
currentUserId,
preview,
);
return message;
}
async markMessageSeen(currentUserId: string, messageId: string) {
const message = await this.chatRepository.findMessageById(messageId);
if (!message) {
throw new NotFoundException('Message not found');
}
await this.assertConversationMember(currentUserId, message.conversationId.toString());
await this.chatRepository.markMessageSeen(message.id, currentUserId);
await this.chatRepository.clearConversationUnreadForUser(message.conversationId.toString(), currentUserId);
return { success: true };
}
async unsendMessage(currentUserId: string, messageId: string) {
const message = await this.chatRepository.findMessageById(messageId);
if (!message) {
throw new NotFoundException('Message not found');
}
if (message.senderId.toString() !== currentUserId) {
throw new ForbiddenException('You can only unsend your own messages');
}
const updated = await this.chatRepository.unsendMessage(messageId, currentUserId);
if (!updated) {
throw new NotFoundException('Message not found');
}
return updated;
}
async blockUser(currentUserId: string, targetUserId: string) {
if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id');
}
if (currentUserId === targetUserId) {
throw new BadRequestException('You cannot block yourself');
}
const target = await this.usersRepository.findById(targetUserId);
if (!target) {
throw new NotFoundException('Target user not found');
}
await this.chatRepository.createBlock(currentUserId, targetUserId);
return { blocked: true, targetUserId };
}
async unblockUser(currentUserId: string, targetUserId: string) {
if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id');
}
await this.chatRepository.removeBlock(currentUserId, targetUserId);
return { blocked: false, targetUserId };
}
async getBlockStatus(currentUserId: string, targetUserId: string) {
if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id');
}
const iBlocked = !!(await this.chatRepository.findBlock(currentUserId, targetUserId));
const blockedMe = !!(await this.chatRepository.findBlock(targetUserId, currentUserId));
return { targetUserId, iBlocked, blockedMe };
}
async getMyBlockedUsers(currentUserId: string) {
const items = await this.chatRepository.findBlocksByBlocker(currentUserId);
return { items };
}
async assertConversationMember(userId: string, conversationId: string) {
if (!Types.ObjectId.isValid(conversationId)) {
throw new BadRequestException('Invalid conversation id');
}
const conversation = await this.chatRepository.findConversationById(conversationId);
if (!conversation) {
throw new NotFoundException('Conversation not found');
}
const isMember = conversation.participantIds.some((id) => id.toString() === userId);
if (!isMember) {
throw new ForbiddenException('You are not a member of this conversation');
}
return conversation;
}
private async assertNoChatBlockInConversation(currentUserId: string, participantIds: string[]) {
for (const participantId of participantIds) {
if (participantId === currentUserId) {
continue;
}
const block = await this.chatRepository.findAnyBlockBetween(currentUserId, participantId);
if (block) {
throw new ForbiddenException('Cannot send message because one of participants is blocked');
}
}
}
}

عرض الملف

@@ -0,0 +1,19 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
export class CreateConversationDto {
@IsArray()
@IsString({ each: true })
participantIds!: string[];
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isGroup?: boolean;
@ApiPropertyOptional({ maxLength: 120 })
@IsOptional()
@IsString()
@Length(1, 120)
title?: string;
}

عرض الملف

@@ -0,0 +1,3 @@
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
export class MessageQueryDto extends PaginationQueryDto {}

عرض الملف

@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
export class SendMessageDto {
@IsString()
conversationId!: string;
@ApiPropertyOptional({ maxLength: 4000 })
@IsOptional()
@IsString()
@Length(1, 4000)
content?: string;
@ApiPropertyOptional({ enum: ['text', 'image', 'video', 'audio'], default: 'text' })
@IsOptional()
@IsEnum(['text', 'image', 'video', 'audio'])
messageType?: 'text' | 'image' | 'video' | 'audio';
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_tld: false })
mediaUrl?: string;
}

عرض الملف

@@ -0,0 +1,17 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
export type ChatBlockDocument = HydratedDocument<ChatBlock>;
@Schema({ timestamps: true, versionKey: false })
export class ChatBlock {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
blockerId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
blockedId!: Types.ObjectId;
}
export const ChatBlockSchema = SchemaFactory.createForClass(ChatBlock);
ChatBlockSchema.index({ blockerId: 1, blockedId: 1 }, { unique: true });

عرض الملف

@@ -0,0 +1,37 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
export type ConversationDocument = HydratedDocument<Conversation>;
@Schema({ timestamps: true, versionKey: false })
export class Conversation {
@Prop({ type: [Types.ObjectId], ref: User.name, required: true, index: true })
participantIds!: Types.ObjectId[];
@Prop({ default: false, index: true })
isGroup!: boolean;
@Prop({ default: '', maxlength: 120, trim: true })
title!: string;
@Prop({ type: Types.ObjectId, ref: User.name, required: false, index: true })
createdBy?: Types.ObjectId;
@Prop({ type: Types.ObjectId, required: false, index: true })
lastMessageId?: Types.ObjectId;
@Prop({ default: '', maxlength: 4000 })
lastMessageText!: string;
@Prop({ type: Date, required: false, index: true })
lastMessageAt?: Date;
@Prop({ type: Map, of: Number, default: {} })
unreadCountByUser!: Map<string, number>;
}
export const ConversationSchema = SchemaFactory.createForClass(Conversation);
ConversationSchema.index({ participantIds: 1, updatedAt: -1 });
ConversationSchema.index({ lastMessageAt: -1, updatedAt: -1 });
ConversationSchema.index({ participantIds: 1, isGroup: 1, lastMessageAt: -1 });

عرض الملف

@@ -0,0 +1,33 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
export type MessageDocument = HydratedDocument<Message>;
@Schema({ timestamps: true, versionKey: false })
export class Message {
@Prop({ type: Types.ObjectId, required: true, index: true })
conversationId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
senderId!: Types.ObjectId;
@Prop({ required: false, default: '', maxlength: 4000 })
content!: string;
@Prop({ enum: ['text', 'image', 'video', 'audio'], default: 'text', index: true })
messageType!: 'text' | 'image' | 'video' | 'audio';
@Prop({ required: false, default: '' })
mediaUrl!: string;
@Prop({ type: [Types.ObjectId], ref: User.name, default: [] })
seenBy!: Types.ObjectId[];
@Prop({ default: false, index: true })
isUnsent!: boolean;
}
export const MessageSchema = SchemaFactory.createForClass(Message);
MessageSchema.index({ conversationId: 1, createdAt: -1 });
MessageSchema.index({ conversationId: 1, isUnsent: 1, createdAt: -1 });

عرض الملف

@@ -0,0 +1,50 @@
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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { CommentQueryDto } from './dto/comment-query.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
import { CommentsService } from './comments.service';
@ApiTags('Comments')
@Controller('comments')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post()
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreateCommentDto) {
return this.commentsService.create(user.sub, dto);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('post/:postId')
async findByPost(@Param('postId') postId: string, @Query() query: CommentQueryDto) {
return this.commentsService.findByPost(postId, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':commentId/replies')
async findReplies(@Param('commentId') commentId: string, @Query() query: CommentQueryDto) {
return this.commentsService.findReplies(commentId, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Delete(':commentId')
async remove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
return this.commentsService.remove(user.sub, commentId);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Delete('admin/:commentId')
async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId);
}
}

عرض الملف

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuditModule } from '../audit/audit.module';
import { PostsModule } from '../posts/posts.module';
import { Comment, CommentSchema } from './schemas/comment.schema';
import { CommentsController } from './comments.controller';
import { CommentsService } from './comments.service';
import { CommentsRepository } from './comments.repository';
@Module({
imports: [
AuditModule,
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
PostsModule,
],
controllers: [CommentsController],
providers: [CommentsService, CommentsRepository],
exports: [CommentsService, CommentsRepository],
})
export class CommentsModule {}

عرض الملف

@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
import { Comment, CommentDocument } from './schemas/comment.schema';
@Injectable()
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 },
};
}
async create(
payload: { postId: string; authorId: string; content: string; parentCommentId?: string },
session?: ClientSession,
) {
return this.commentModel.create({
postId: new Types.ObjectId(payload.postId),
authorId: new Types.ObjectId(payload.authorId),
content: payload.content,
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
}, { session });
}
async findById(commentId: string): Promise<CommentDocument | null> {
if (!Types.ObjectId.isValid(commentId)) {
return null;
}
return this.commentModel
.findOne({ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } })
.exec();
}
async deleteById(commentId: string, deletedBy?: string, session?: ClientSession): Promise<boolean> {
if (!Types.ObjectId.isValid(commentId)) {
return false;
}
const deletedByObjectId =
deletedBy && Types.ObjectId.isValid(deletedBy) ? new Types.ObjectId(deletedBy) : null;
const updated = await this.commentModel
.findOneAndUpdate(
{ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } },
{ isDeleted: true, deletedAt: new Date(), deletedBy: deletedByObjectId },
{ new: false, session },
)
.exec();
return !!updated;
}
async findMany(filter: FilterQuery<CommentDocument>, skip: number, limit: number) {
return this.commentModel
.find(this.withActiveFilter(filter))
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.exec();
}
async count(filter: FilterQuery<CommentDocument>): Promise<number> {
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
}
async countByPost(postId: string): Promise<number> {
return this.commentModel
.countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } })
.exec();
}
}

عرض الملف

@@ -0,0 +1,114 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { AuditService } from '../audit/audit.service';
import { PostsRepository } from '../posts/posts.repository';
import { CommentQueryDto } from './dto/comment-query.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
import { CommentsRepository } from './comments.repository';
@Injectable()
export class CommentsService {
constructor(
private readonly commentsRepository: CommentsRepository,
private readonly postsRepository: PostsRepository,
private readonly auditService: AuditService,
) {}
async create(userId: string, dto: CreateCommentDto) {
const post = await this.postsRepository.findById(dto.postId);
if (!post) {
throw new NotFoundException('Post not found');
}
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');
}
}
const comment = await this.commentsRepository.create({
postId: dto.postId,
authorId: userId,
content: dto.content,
parentCommentId: dto.parentCommentId,
});
await this.syncCommentsCount(dto.postId);
return comment;
}
async remove(userId: string, commentId: string) {
const comment = await this.commentsRepository.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
if (comment.authorId.toString() !== userId) {
throw new ForbiddenException('You can only delete your own comments');
}
await this.commentsRepository.deleteById(commentId, userId);
await this.syncCommentsCount(comment.postId.toString());
return { success: true };
}
async removeBySuperAdmin(superAdminIdentifier: string, commentId: string) {
const comment = await this.commentsRepository.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
await this.commentsRepository.deleteById(commentId, superAdminIdentifier);
await this.syncCommentsCount(comment.postId.toString());
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'comment_delete',
'comment',
commentId,
{ postId: comment.postId.toString() },
);
return { success: true, message: 'Comment deleted by superadmin' };
}
async findByPost(postId: string, query: CommentQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
this.commentsRepository.findMany({ postId, parentCommentId: { $exists: false } }, skip, limit),
this.commentsRepository.count({ postId, parentCommentId: { $exists: false } }),
]);
return {
items,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
}
async findReplies(parentCommentId: string, query: CommentQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
this.commentsRepository.findMany({ parentCommentId }, skip, limit),
this.commentsRepository.count({ parentCommentId }),
]);
return {
items,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
}
private async syncCommentsCount(postId: string): Promise<void> {
const totalComments = await this.commentsRepository.countByPost(postId);
await this.postsRepository.setCommentsCount(postId, totalComments);
}
}

عرض الملف

@@ -0,0 +1,3 @@
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
export class CommentQueryDto extends PaginationQueryDto {}

عرض الملف

@@ -0,0 +1,18 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsMongoId, IsOptional, IsString, Length } from 'class-validator';
export class CreateCommentDto {
@ApiProperty()
@IsMongoId()
postId!: string;
@ApiProperty()
@IsString()
@Length(1, 1000)
content!: string;
@ApiPropertyOptional()
@IsOptional()
@IsMongoId()
parentCommentId?: string;
}

عرض الملف

@@ -0,0 +1,8 @@
import { IsOptional, IsString, Length } from 'class-validator';
export class UpdateCommentDto {
@IsOptional()
@IsString()
@Length(1, 1000)
content?: string;
}

عرض الملف

@@ -0,0 +1,34 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { Post } from '../../posts/schemas/post.schema';
import { User } from '../../users/schemas/user.schema';
export type CommentDocument = HydratedDocument<Comment>;
@Schema({ timestamps: true, versionKey: false })
export class Comment {
@Prop({ type: Types.ObjectId, ref: Post.name, required: true, index: true })
postId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
authorId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, required: false, index: true })
parentCommentId?: Types.ObjectId;
@Prop({ required: true, maxlength: 1000 })
content!: string;
@Prop({ default: false, index: true })
isDeleted!: boolean;
@Prop({ type: Date, default: null })
deletedAt?: Date | null;
@Prop({ type: Types.ObjectId, ref: User.name, default: null })
deletedBy?: Types.ObjectId | null;
}
export const CommentSchema = SchemaFactory.createForClass(Comment);
CommentSchema.index({ postId: 1, createdAt: -1 });
CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 });

عرض الملف

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { EmailService } from './email.service';
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

عرض الملف

@@ -0,0 +1,134 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private transporter: nodemailer.Transporter<SMTPTransport.SentMessageInfo> | null = null;
constructor(private readonly configService: ConfigService) {}
async sendVerificationCode(email: string, code: string, expiresMinutes: number): Promise<void> {
const subject = 'تأكيد البريد الإلكتروني - Oudelaa';
const text = [
'مرحباً،',
`رمز تأكيد الحساب الخاص بك هو: ${code}`,
`صلاحية الرمز: ${expiresMinutes} دقيقة.`,
'إذا لم تطلب هذا الرمز، تجاهل هذه الرسالة.',
].join('\n');
const html = this.buildCodeEmailHtml({
title: 'تأكيد البريد الإلكتروني',
intro: 'استخدم الرمز التالي لإكمال تفعيل حسابك في Oudelaa:',
code,
expiresMinutes,
footerNote: 'إذا لم تطلب هذا الرمز، يمكنك تجاهل الرسالة بأمان.',
});
await this.send(email, subject, text, html);
}
async sendPasswordResetCode(email: string, code: string, expiresMinutes: number): Promise<void> {
const subject = 'إعادة تعيين كلمة المرور - Oudelaa';
const text = [
'مرحباً،',
`رمز إعادة تعيين كلمة المرور هو: ${code}`,
`صلاحية الرمز: ${expiresMinutes} دقيقة.`,
'إذا لم تطلب إعادة تعيين كلمة المرور، ننصحك بتغيير كلمة المرور مباشرة.',
].join('\n');
const html = this.buildCodeEmailHtml({
title: 'إعادة تعيين كلمة المرور',
intro: 'استخدم الرمز التالي لإعادة تعيين كلمة المرور:',
code,
expiresMinutes,
footerNote: 'إذا لم تطلب إعادة التعيين، تجاهل هذه الرسالة وقم بمراجعة أمان حسابك.',
});
await this.send(email, subject, text, html);
}
private buildCodeEmailHtml(params: {
title: string;
intro: string;
code: string;
expiresMinutes: number;
footerNote: string;
}): string {
return `
<div style="font-family: Arial, sans-serif; background:#f6f8fb; padding:24px; direction:rtl; text-align:right;">
<div style="max-width:520px; margin:0 auto; background:#ffffff; border-radius:12px; padding:24px; border:1px solid #e8edf3;">
<h2 style="margin:0 0 12px; color:#111827;">${params.title}</h2>
<p style="margin:0 0 16px; color:#374151; line-height:1.7;">${params.intro}</p>
<div style="font-size:32px; letter-spacing:6px; font-weight:700; color:#0f172a; background:#f1f5f9; border-radius:10px; text-align:center; padding:14px 8px; margin:0 0 14px;">
${params.code}
</div>
<p style="margin:0 0 10px; color:#475569;">صلاحية الرمز: <strong>${params.expiresMinutes} دقيقة</strong></p>
<p style="margin:0; color:#64748b; font-size:13px;">${params.footerNote}</p>
<hr style="border:none; border-top:1px solid #e5e7eb; margin:18px 0;" />
<p style="margin:0; color:#94a3b8; font-size:12px;">Oudelaa Team</p>
</div>
</div>
`;
}
private async send(to: string, subject: string, text: string, html: string): Promise<void> {
const enabled = this.configService.get<boolean>('email.enabled', { infer: true });
if (!enabled) {
return;
}
const fromName = this.configService.get<string>('email.fromName', { infer: true }) ?? 'Oudelaa';
const fromEmail = this.configService.get<string>('email.fromEmail', { infer: true }) ?? '';
if (!fromEmail) {
throw new ServiceUnavailableException('Email sender is not configured');
}
const transporter = this.getTransporter();
try {
await transporter.sendMail({
from: `${fromName} <${fromEmail}>`,
to,
subject,
text,
html,
});
} catch (error) {
this.logger.error(`Failed to send email to ${to}`, error as Error);
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
if (nodeEnv === 'development') {
const err = error as Error & { code?: string; message?: string };
throw new ServiceUnavailableException(
`Failed to send verification email (${err.code ?? 'SMTP_ERROR'}: ${err.message ?? 'unknown'})`,
);
}
throw new ServiceUnavailableException('Failed to send verification email');
}
}
private getTransporter(): nodemailer.Transporter<SMTPTransport.SentMessageInfo> {
if (this.transporter) {
return this.transporter;
}
const host = this.configService.get<string>('email.smtpHost', { infer: true }) ?? '';
const port = this.configService.get<number>('email.smtpPort', { infer: true }) ?? 587;
const secure = this.configService.get<boolean>('email.smtpSecure', { infer: true }) ?? false;
const user = this.configService.get<string>('email.smtpUser', { infer: true }) ?? '';
const pass = this.configService.get<string>('email.smtpPass', { infer: true }) ?? '';
if (!host || !user || !pass) {
throw new ServiceUnavailableException('SMTP settings are not configured');
}
this.transporter = nodemailer.createTransport({
host,
port,
secure,
auth: {
user,
pass,
},
});
return this.transporter;
}
}

عرض الملف

@@ -0,0 +1,22 @@
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { IsBoolean, IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { PostType } from '../../../common/enums/post-type.enum';
export class FeedQueryDto extends PaginationQueryDto {
@IsOptional()
@IsEnum(PostType)
preferredPostType?: PostType;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
followingOnly?: boolean;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(500)
radiusKm?: number;
}

عرض الملف

@@ -0,0 +1,27 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
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 { FeedQueryDto } from './dto/feed-query.dto';
import { FeedService } from './feed.service';
@ApiTags('Feed')
@Controller('feed')
export class FeedController {
constructor(private readonly feedService: FeedService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('me')
async myFeed(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
return this.feedService.getMyFeed(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('trending')
async trending(@Query() query: FeedQueryDto) {
return this.feedService.getTrending(query);
}
}

عرض الملف

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Follow, FollowSchema } from '../follows/schemas/follow.schema';
import { Post, PostSchema } from '../posts/schemas/post.schema';
import { UsersModule } from '../users/users.module';
import { FeedController } from './feed.controller';
import { FeedService } from './feed.service';
import { FeedRepository } from './feed.repository';
@Module({
imports: [
UsersModule,
MongooseModule.forFeature([
{ name: Post.name, schema: PostSchema },
{ name: Follow.name, schema: FollowSchema },
]),
],
controllers: [FeedController],
providers: [FeedService, FeedRepository],
exports: [FeedService],
})
export class FeedModule {}

عرض الملف

@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model, Types } from 'mongoose';
import { Follow, FollowDocument } from '../follows/schemas/follow.schema';
import { Post, PostDocument } from '../posts/schemas/post.schema';
@Injectable()
export class FeedRepository {
constructor(
@InjectModel(Post.name) private readonly postModel: Model<PostDocument>,
@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>,
) {}
async findFollowingIds(userId: string): Promise<string[]> {
const rows = await this.followModel
.find({ followerId: new Types.ObjectId(userId) })
.select({ followingId: 1 })
.lean()
.exec();
return rows.map((row) => row.followingId.toString());
}
async findCandidatePosts(
filter: FilterQuery<PostDocument>,
limit: number,
): Promise<PostDocument[]> {
const activeFilter: FilterQuery<PostDocument> = {
...filter,
isDeleted: { $ne: true },
};
return this.postModel
.find(activeFilter)
.populate({
path: 'authorId',
select:
'name username stageName avatar isVerified isDisabled location latitude longitude musicGenres musicRoles favoriteInstruments favoriteMaqamat',
})
.sort({ createdAt: -1 })
.limit(limit)
.exec();
}
async findTrendingPublicPosts(skip: number, limit: number): Promise<PostDocument[]> {
return this.postModel
.find({ visibility: 'public', isDeleted: { $ne: true } })
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
.sort({ likesCount: -1, commentsCount: -1, savesCount: -1, createdAt: -1 })
.skip(skip)
.limit(limit)
.exec();
}
async count(filter: FilterQuery<PostDocument>): Promise<number> {
return this.postModel.countDocuments({ ...filter, isDeleted: { $ne: true } }).exec();
}
}

عرض الملف

@@ -0,0 +1,212 @@
import { Injectable, NotFoundException } from '@nestjs/common';
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 { UserDocument } from '../users/schemas/user.schema';
import { FeedQueryDto } from './dto/feed-query.dto';
import { FeedRepository } from './feed.repository';
@Injectable()
export class FeedService {
constructor(
private readonly feedRepository: FeedRepository,
private readonly usersRepository: UsersRepository,
) {}
async getMyFeed(currentUserId: string, query: FeedQueryDto) {
const currentUser = await this.usersRepository.findById(currentUserId);
if (!currentUser) {
throw new NotFoundException('Current user not found');
}
const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor);
const page = query.page ?? 1;
const followingOnly = query.followingOnly ?? false;
const radiusKm = query.radiusKm ?? 30;
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 candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
const scored = candidates
.filter((post) => {
if (!query.preferredPostType) {
return true;
}
return post.postType === query.preferredPostType;
})
.map((post) => ({
post,
score: this.scorePost({
currentUser,
currentUserId,
followingIds,
post,
preferredPostType: query.preferredPostType,
radiusKm,
}),
}))
.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(),
);
const total = scored.length;
const skip = cursorOffset ?? (page - 1) * limit;
const items = scored.slice(skip, skip + limit).map((entry) => ({
...entry.post.toObject(),
feedScore: Number(entry.score.toFixed(3)),
}));
const nextOffset = skip + items.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return {
items,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
nextCursor,
};
}
async getTrending(query: FeedQueryDto) {
const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor);
const page = query.page ?? 1;
const skip = cursorOffset ?? (page - 1) * limit;
const [items, total] = await Promise.all([
this.feedRepository.findTrendingPublicPosts(skip, limit),
this.feedRepository.count({ visibility: PostVisibility.PUBLIC }),
]);
const nextOffset = skip + items.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return {
items,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
nextCursor,
};
}
private scorePost(input: {
currentUser: UserDocument;
currentUserId: string;
followingIds: string[];
post: 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 isOwnPost = authorId === currentUserId;
const isFollowing = followingIds.includes(authorId);
const ageMs = Date.now() - new Date(post.createdAt).getTime();
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 hashtagMatches = this.intersectionCount(
this.buildPreferenceTokens(currentUser),
(post.hashtags ?? []).map((x: string) => x.toLowerCase()),
);
const distanceKm = this.computeDistanceKm(
currentUser.latitude,
currentUser.longitude,
author?.latitude ?? null,
author?.longitude ?? null,
);
const nearbyBoost =
typeof distanceKm === 'number' && distanceKm <= radiusKm ? Math.max(0, 25 - distanceKm / 2) : 0;
let score = 0;
score += engagement;
score += freshness;
score += isOwnPost ? 10 : 0;
score += isFollowing ? 40 : 0;
score += post.postType === preferredPostType ? 18 : 0;
score += hashtagMatches * 9;
score += nearbyBoost;
score += author?.isVerified ? 8 : 0;
score += Math.min(20, Math.floor((author?.followersCount ?? 0) / 200));
return score;
}
private buildPreferenceTokens(user: UserDocument): string[] {
const tokens = [
...(user.musicGenres ?? []),
...(user.favoriteInstruments ?? []),
...(user.favoriteMaqamat ?? []),
...(user.musicRoles ?? []),
]
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
return Array.from(new Set(tokens));
}
private intersectionCount(a: string[], b: string[]): number {
if (!a.length || !b.length) {
return 0;
}
const right = new Set(b);
let count = 0;
for (const item of a) {
if (right.has(item)) {
count += 1;
}
}
return count;
}
private computeDistanceKm(
lat1: number | null | undefined,
lon1: number | null | undefined,
lat2: number | null | undefined,
lon2: number | null | undefined,
): number | null {
if (
typeof lat1 !== 'number' ||
typeof lon1 !== 'number' ||
typeof lat2 !== 'number' ||
typeof lon2 !== 'number'
) {
return null;
}
const toRad = (deg: number) => (deg * Math.PI) / 180;
const earthKm = 6371;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthKm * c;
}
}

لم تُعرض بعض الملفات لأن الكثير من الملفات تغيرت في هذا الاختلاف إظهار المزيد