first commit
هذا الالتزام موجود في:
44
.env.example
Normal file
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
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
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": true
|
||||
}
|
||||
3
README.md
Normal file
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
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
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
10872
package-lock.json
مولّد
Normal file
تم حذف اختلاف الملف لأن الملف كبير جداً
تحميل الاختلاف
67
package.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"
|
||||
}
|
||||
}
|
||||
2745
postman/Oudelaa-Auth-Users-Posts.postman_collection.json
Normal file
2745
postman/Oudelaa-Auth-Users-Posts.postman_collection.json
Normal file
تم حذف اختلاف الملف لأن الملف كبير جداً
تحميل الاختلاف
39
postman/Oudelaa-Local.postman_environment.json
Normal file
39
postman/Oudelaa-Local.postman_environment.json
Normal file
@@ -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"
|
||||
}
|
||||
209
postman/superadmin-dashboard-config.json
Normal file
209
postman/superadmin-dashboard-config.json
Normal file
@@ -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
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
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
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' };
|
||||
}
|
||||
}
|
||||
6
src/common/decorators/current-user.decorator.ts
Normal file
6
src/common/decorators/current-user.decorator.ts
Normal file
@@ -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;
|
||||
});
|
||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@@ -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);
|
||||
5
src/common/decorators/roles.decorator.ts
Normal file
5
src/common/decorators/roles.decorator.ts
Normal file
@@ -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);
|
||||
11
src/common/decorators/throttle.decorator.ts
Normal file
11
src/common/decorators/throttle.decorator.ts
Normal file
@@ -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);
|
||||
6
src/common/dto/object-id-param.dto.ts
Normal file
6
src/common/dto/object-id-param.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsMongoId } from 'class-validator';
|
||||
|
||||
export class ObjectIdParamDto {
|
||||
@IsMongoId()
|
||||
id!: string;
|
||||
}
|
||||
22
src/common/dto/pagination-query.dto.ts
Normal file
22
src/common/dto/pagination-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
6
src/common/enums/experience-level.enum.ts
Normal file
6
src/common/enums/experience-level.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum ExperienceLevel {
|
||||
BEGINNER = 'beginner',
|
||||
INTERMEDIATE = 'intermediate',
|
||||
ADVANCED = 'advanced',
|
||||
PROFESSIONAL = 'professional',
|
||||
}
|
||||
8
src/common/enums/music-role.enum.ts
Normal file
8
src/common/enums/music-role.enum.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum MusicRole {
|
||||
INSTRUMENTALIST = 'instrumentalist',
|
||||
SINGER = 'singer',
|
||||
COMPOSER = 'composer',
|
||||
LYRICIST = 'lyricist',
|
||||
PRODUCER = 'producer',
|
||||
ARRANGER = 'arranger',
|
||||
}
|
||||
6
src/common/enums/notification-type.enum.ts
Normal file
6
src/common/enums/notification-type.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum NotificationType {
|
||||
LIKE = 'like',
|
||||
COMMENT = 'comment',
|
||||
FOLLOW = 'follow',
|
||||
MESSAGE = 'message',
|
||||
}
|
||||
5
src/common/enums/post-type.enum.ts
Normal file
5
src/common/enums/post-type.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum PostType {
|
||||
TEXT = 'text',
|
||||
VIDEO = 'video',
|
||||
AUDIO = 'audio',
|
||||
}
|
||||
5
src/common/enums/post-visibility.enum.ts
Normal file
5
src/common/enums/post-visibility.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum PostVisibility {
|
||||
PUBLIC = 'public',
|
||||
FOLLOWERS = 'followers',
|
||||
PRIVATE = 'private',
|
||||
}
|
||||
4
src/common/enums/token-type.enum.ts
Normal file
4
src/common/enums/token-type.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum TokenType {
|
||||
ACCESS = 'access',
|
||||
REFRESH = 'refresh',
|
||||
}
|
||||
5
src/common/enums/user-role.enum.ts
Normal file
5
src/common/enums/user-role.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum UserRole {
|
||||
USER = 'user',
|
||||
ADMIN = 'admin',
|
||||
SUPERADMIN = 'superadmin',
|
||||
}
|
||||
5
src/common/guards/jwt-auth.guard.ts
Normal file
5
src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
5
src/common/guards/jwt-refresh.guard.ts
Normal file
5
src/common/guards/jwt-refresh.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
|
||||
29
src/common/guards/roles.guard.ts
Normal file
29
src/common/guards/roles.guard.ts
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
5
src/common/guards/super-admin-jwt-auth.guard.ts
Normal file
5
src/common/guards/super-admin-jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class SuperAdminJwtAuthGuard extends AuthGuard('superadmin-jwt') {}
|
||||
50
src/common/guards/throttle.guard.ts
Normal file
50
src/common/guards/throttle.guard.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
25
src/common/interceptors/response-envelope.interceptor.ts
Normal file
25
src/common/interceptors/response-envelope.interceptor.ts
Normal file
@@ -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(),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
7
src/common/interfaces/jwt-payload.interface.ts
Normal file
7
src/common/interfaces/jwt-payload.interface.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
role?: string;
|
||||
tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh';
|
||||
email?: string;
|
||||
}
|
||||
13
src/common/utils/cursor.util.spec.ts
Normal file
13
src/common/utils/cursor.util.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
19
src/common/utils/cursor.util.ts
Normal file
19
src/common/utils/cursor.util.ts
Normal file
@@ -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;
|
||||
}
|
||||
};
|
||||
7
src/common/utils/hash.util.ts
Normal file
7
src/common/utils/hash.util.ts
Normal file
@@ -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);
|
||||
69
src/config/configuration.ts
Normal file
69
src/config/configuration.ts
Normal file
@@ -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
5
src/config/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const APP_CONSTANTS = {
|
||||
DEFAULT_PAGE: 1,
|
||||
DEFAULT_LIMIT: 20,
|
||||
MAX_LIMIT: 100,
|
||||
};
|
||||
17
src/config/swagger.config.ts
Normal file
17
src/config/swagger.config.ts
Normal file
@@ -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);
|
||||
};
|
||||
44
src/config/validation.schema.ts
Normal file
44
src/config/validation.schema.ts
Normal file
@@ -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'),
|
||||
});
|
||||
16
src/database/database.module.ts
Normal file
16
src/database/database.module.ts
Normal file
@@ -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 {}
|
||||
7
src/database/mongoose-options.factory.ts
Normal file
7
src/database/mongoose-options.factory.ts
Normal file
@@ -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
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();
|
||||
19
src/modules/audit/audit.module.ts
Normal file
19
src/modules/audit/audit.module.ts
Normal file
@@ -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 {}
|
||||
29
src/modules/audit/audit.repository.ts
Normal file
29
src/modules/audit/audit.repository.ts
Normal file
@@ -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 ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/modules/audit/audit.service.ts
Normal file
24
src/modules/audit/audit.service.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
31
src/modules/audit/schemas/audit-log.schema.ts
Normal file
31
src/modules/audit/schemas/audit-log.schema.ts
Normal file
@@ -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 });
|
||||
154
src/modules/auth/auth.controller.ts
Normal file
154
src/modules/auth/auth.controller.ts
Normal file
@@ -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 };
|
||||
}
|
||||
}
|
||||
73
src/modules/auth/auth.module.ts
Normal file
73
src/modules/auth/auth.module.ts
Normal file
@@ -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 {}
|
||||
237
src/modules/auth/auth.repository.ts
Normal file
237
src/modules/auth/auth.repository.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
666
src/modules/auth/auth.service.ts
Normal file
666
src/modules/auth/auth.service.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
16
src/modules/auth/dto/auth-response.dto.ts
Normal file
16
src/modules/auth/dto/auth-response.dto.ts
Normal file
@@ -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>;
|
||||
}
|
||||
8
src/modules/auth/dto/forgot-password.dto.ts
Normal file
8
src/modules/auth/dto/forgot-password.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
9
src/modules/auth/dto/google-token-login.dto.ts
Normal file
9
src/modules/auth/dto/google-token-login.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
13
src/modules/auth/dto/login.dto.ts
Normal file
13
src/modules/auth/dto/login.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
9
src/modules/auth/dto/refresh-token.dto.ts
Normal file
9
src/modules/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
refreshToken!: string;
|
||||
}
|
||||
18
src/modules/auth/dto/register-basic.dto.ts
Normal file
18
src/modules/auth/dto/register-basic.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
112
src/modules/auth/dto/register.dto.ts
Normal file
112
src/modules/auth/dto/register.dto.ts
Normal file
@@ -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[];
|
||||
}
|
||||
18
src/modules/auth/dto/reset-password.dto.ts
Normal file
18
src/modules/auth/dto/reset-password.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
8
src/modules/auth/dto/send-email-verification.dto.ts
Normal file
8
src/modules/auth/dto/send-email-verification.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
13
src/modules/auth/dto/super-admin-login.dto.ts
Normal file
13
src/modules/auth/dto/super-admin-login.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
14
src/modules/auth/dto/verify-email.dto.ts
Normal file
14
src/modules/auth/dto/verify-email.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
14
src/modules/auth/dto/verify-reset-code.dto.ts
Normal file
14
src/modules/auth/dto/verify-reset-code.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
5
src/modules/auth/guards/google-auth.guard.ts
Normal file
5
src/modules/auth/guards/google-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleAuthGuard extends AuthGuard('google') {}
|
||||
28
src/modules/auth/schemas/email-verification-code.schema.ts
Normal file
28
src/modules/auth/schemas/email-verification-code.schema.ts
Normal file
@@ -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 });
|
||||
31
src/modules/auth/schemas/password-reset-code.schema.ts
Normal file
31
src/modules/auth/schemas/password-reset-code.schema.ts
Normal file
@@ -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 });
|
||||
32
src/modules/auth/schemas/refresh-token.schema.ts
Normal file
32
src/modules/auth/schemas/refresh-token.schema.ts
Normal file
@@ -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 });
|
||||
23
src/modules/auth/schemas/super-admin-refresh-token.schema.ts
Normal file
23
src/modules/auth/schemas/super-admin-refresh-token.schema.ts
Normal file
@@ -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 });
|
||||
42
src/modules/auth/strategies/google.strategy.ts
Normal file
42
src/modules/auth/strategies/google.strategy.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
26
src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
26
src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
24
src/modules/auth/strategies/jwt.strategy.ts
Normal file
24
src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
24
src/modules/auth/strategies/super-admin-jwt.strategy.ts
Normal file
24
src/modules/auth/strategies/super-admin-jwt.strategy.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
8
src/modules/auth/types/token-pair.type.ts
Normal file
8
src/modules/auth/types/token-pair.type.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type TokenPair = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export type AuthResult = TokenPair & {
|
||||
user: Record<string, unknown>;
|
||||
};
|
||||
78
src/modules/chat/chat.controller.ts
Normal file
78
src/modules/chat/chat.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
137
src/modules/chat/chat.gateway.ts
Normal file
137
src/modules/chat/chat.gateway.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
}
|
||||
29
src/modules/chat/chat.module.ts
Normal file
29
src/modules/chat/chat.module.ts
Normal file
@@ -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 {}
|
||||
221
src/modules/chat/chat.repository.ts
Normal file
221
src/modules/chat/chat.repository.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
250
src/modules/chat/chat.service.ts
Normal file
250
src/modules/chat/chat.service.ts
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/modules/chat/dto/create-conversation.dto.ts
Normal file
19
src/modules/chat/dto/create-conversation.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
3
src/modules/chat/dto/message-query.dto.ts
Normal file
3
src/modules/chat/dto/message-query.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export class MessageQueryDto extends PaginationQueryDto {}
|
||||
23
src/modules/chat/dto/send-message.dto.ts
Normal file
23
src/modules/chat/dto/send-message.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
17
src/modules/chat/schemas/chat-block.schema.ts
Normal file
17
src/modules/chat/schemas/chat-block.schema.ts
Normal file
@@ -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 });
|
||||
37
src/modules/chat/schemas/conversation.schema.ts
Normal file
37
src/modules/chat/schemas/conversation.schema.ts
Normal file
@@ -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 });
|
||||
33
src/modules/chat/schemas/message.schema.ts
Normal file
33
src/modules/chat/schemas/message.schema.ts
Normal file
@@ -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 });
|
||||
50
src/modules/comments/comments.controller.ts
Normal file
50
src/modules/comments/comments.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
20
src/modules/comments/comments.module.ts
Normal file
20
src/modules/comments/comments.module.ts
Normal file
@@ -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 {}
|
||||
77
src/modules/comments/comments.repository.ts
Normal file
77
src/modules/comments/comments.repository.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
114
src/modules/comments/comments.service.ts
Normal file
114
src/modules/comments/comments.service.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
3
src/modules/comments/dto/comment-query.dto.ts
Normal file
3
src/modules/comments/dto/comment-query.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export class CommentQueryDto extends PaginationQueryDto {}
|
||||
18
src/modules/comments/dto/create-comment.dto.ts
Normal file
18
src/modules/comments/dto/create-comment.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
8
src/modules/comments/dto/update-comment.dto.ts
Normal file
8
src/modules/comments/dto/update-comment.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class UpdateCommentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 1000)
|
||||
content?: string;
|
||||
}
|
||||
34
src/modules/comments/schemas/comment.schema.ts
Normal file
34
src/modules/comments/schemas/comment.schema.ts
Normal file
@@ -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 });
|
||||
8
src/modules/email/email.module.ts
Normal file
8
src/modules/email/email.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmailService } from './email.service';
|
||||
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
134
src/modules/email/email.service.ts
Normal file
134
src/modules/email/email.service.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
22
src/modules/feed/dto/feed-query.dto.ts
Normal file
22
src/modules/feed/dto/feed-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
27
src/modules/feed/feed.controller.ts
Normal file
27
src/modules/feed/feed.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
22
src/modules/feed/feed.module.ts
Normal file
22
src/modules/feed/feed.module.ts
Normal file
@@ -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 {}
|
||||
58
src/modules/feed/feed.repository.ts
Normal file
58
src/modules/feed/feed.repository.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
212
src/modules/feed/feed.service.ts
Normal file
212
src/modules/feed/feed.service.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
لم تُعرض بعض الملفات لأن الكثير من الملفات تغيرت في هذا الاختلاف إظهار المزيد
المرجع في مشكلة جديدة
حظر مستخدم