first commit
This commit is contained in:
@@ -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
@@ -0,0 +1,12 @@
|
|||||||
|
# Build
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
uploads
|
||||||
|
|
||||||
|
# Env files
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"semi": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Oudelaa Backend
|
||||||
|
|
||||||
|
Production-oriented NestJS backend for a social media platform.
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10872
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"id": "c5f5b9cc-9e95-4e19-9a76-cc8f6b95a001",
|
||||||
|
"name": "Oudelaa Local",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"key": "baseUrl",
|
||||||
|
"value": "http://localhost:4000",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "accessToken",
|
||||||
|
"value": "",
|
||||||
|
"type": "secret",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "refreshToken",
|
||||||
|
"value": "",
|
||||||
|
"type": "secret",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "postId",
|
||||||
|
"value": "",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "userId",
|
||||||
|
"value": "",
|
||||||
|
"type": "default",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_postman_variable_scope": "environment",
|
||||||
|
"_postman_exported_at": "2026-04-06T00:00:00.000Z",
|
||||||
|
"_postman_exported_using": "Codex"
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"name": "Oudelaa SuperAdmin Dashboard",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"baseUrl": "{{baseUrl}}",
|
||||||
|
"auth": {
|
||||||
|
"type": "Bearer",
|
||||||
|
"login": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "/auth/superadmin/login",
|
||||||
|
"body": {
|
||||||
|
"email": "admin@oudelaa.com",
|
||||||
|
"password": "SuperAdminStrongPass123!"
|
||||||
|
},
|
||||||
|
"responseTokens": {
|
||||||
|
"accessToken": "superAdminAccessToken",
|
||||||
|
"refreshToken": "superAdminRefreshToken"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refresh": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "/auth/superadmin/refresh",
|
||||||
|
"body": {
|
||||||
|
"refreshToken": "{{superAdminRefreshToken}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logout": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "/auth/superadmin/logout",
|
||||||
|
"body": {
|
||||||
|
"refreshToken": "{{superAdminRefreshToken}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"key": "usersModeration",
|
||||||
|
"title": "Users Moderation",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "Admin Get Users",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "/users/admin?page=1&limit=10",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{superAdminAccessToken}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Admin Get User By Id",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "/users/admin/:userId",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{superAdminAccessToken}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Admin Update User",
|
||||||
|
"method": "PATCH",
|
||||||
|
"url": "/users/admin/:userId",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{superAdminAccessToken}}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"stageName": "Updated by SuperAdmin",
|
||||||
|
"bio": "Profile updated by admin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Disable User",
|
||||||
|
"method": "PATCH",
|
||||||
|
"url": "/users/admin/:userId/disable",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{superAdminAccessToken}}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"reason": "Violation of community guidelines"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Enable User",
|
||||||
|
"method": "PATCH",
|
||||||
|
"url": "/users/admin/:userId/enable",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{superAdminAccessToken}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Delete User",
|
||||||
|
"method": "DELETE",
|
||||||
|
"url": "/users/admin/:userId",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{superAdminAccessToken}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "commentsModeration",
|
||||||
|
"title": "Comments Moderation",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "Admin Delete Comment",
|
||||||
|
"method": "DELETE",
|
||||||
|
"url": "/comments/admin/:commentId",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{superAdminAccessToken}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "securitySessions",
|
||||||
|
"title": "Security & Sessions",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "My Sessions",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "/auth/sessions",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{accessToken}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Revoke Session",
|
||||||
|
"method": "POST",
|
||||||
|
"url": "/auth/sessions/:jti/revoke",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{accessToken}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "feedAlgorithms",
|
||||||
|
"title": "Feed Algorithms",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "My Feed",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "/feed/me?page=1&limit=20&radiusKm=30",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{accessToken}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "My Feed Preferred",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "/feed/me?page=1&limit=20&preferredPostType=video&followingOnly=false&radiusKm=50",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{accessToken}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Trending Feed",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "/feed/trending?page=1&limit=20",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {{accessToken}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": {
|
||||||
|
"tokens": [
|
||||||
|
"superAdminAccessToken",
|
||||||
|
"superAdminRefreshToken",
|
||||||
|
"sessionJti",
|
||||||
|
"targetUserId",
|
||||||
|
"conversationId",
|
||||||
|
"messageId",
|
||||||
|
"accessToken"
|
||||||
|
],
|
||||||
|
"selectedUser": "userId",
|
||||||
|
"selectedComment": "commentId"
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"pages": [
|
||||||
|
"SuperAdmin Login",
|
||||||
|
"Users List",
|
||||||
|
"User Profile",
|
||||||
|
"Update User",
|
||||||
|
"Disable User Modal",
|
||||||
|
"Delete User Confirmation",
|
||||||
|
"Comments Moderation",
|
||||||
|
"Feed Ranking",
|
||||||
|
"Security Sessions"
|
||||||
|
],
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"id": "users",
|
||||||
|
"columns": [
|
||||||
|
"_id",
|
||||||
|
"name",
|
||||||
|
"stageName",
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"role",
|
||||||
|
"isDisabled",
|
||||||
|
"disabledReason",
|
||||||
|
"createdAt"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHealth(): { status: string; service: string } {
|
||||||
|
return { status: 'ok', service: 'oudelaa-backend' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator((_: unknown, context: ExecutionContext) => {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
return request.user;
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = (): ReturnType<typeof SetMetadata> => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: string[]): ReturnType<typeof SetMetadata> =>
|
||||||
|
SetMetadata(ROLES_KEY, roles);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const THROTTLE_META_KEY = 'throttle_meta_key';
|
||||||
|
|
||||||
|
export type ThrottleMeta = {
|
||||||
|
limit: number;
|
||||||
|
windowMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Throttle = (limit: number, windowMs: number) =>
|
||||||
|
SetMetadata(THROTTLE_META_KEY, { limit, windowMs } satisfies ThrottleMeta);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsMongoId } from 'class-validator';
|
||||||
|
|
||||||
|
export class ObjectIdParamDto {
|
||||||
|
@IsMongoId()
|
||||||
|
id!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { APP_CONSTANTS } from '../../config/constants';
|
||||||
|
|
||||||
|
export class PaginationQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = APP_CONSTANTS.DEFAULT_PAGE;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(APP_CONSTANTS.MAX_LIMIT)
|
||||||
|
limit?: number = APP_CONSTANTS.DEFAULT_LIMIT;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cursor?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum ExperienceLevel {
|
||||||
|
BEGINNER = 'beginner',
|
||||||
|
INTERMEDIATE = 'intermediate',
|
||||||
|
ADVANCED = 'advanced',
|
||||||
|
PROFESSIONAL = 'professional',
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export enum MusicRole {
|
||||||
|
INSTRUMENTALIST = 'instrumentalist',
|
||||||
|
SINGER = 'singer',
|
||||||
|
COMPOSER = 'composer',
|
||||||
|
LYRICIST = 'lyricist',
|
||||||
|
PRODUCER = 'producer',
|
||||||
|
ARRANGER = 'arranger',
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum NotificationType {
|
||||||
|
LIKE = 'like',
|
||||||
|
COMMENT = 'comment',
|
||||||
|
FOLLOW = 'follow',
|
||||||
|
MESSAGE = 'message',
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum PostType {
|
||||||
|
TEXT = 'text',
|
||||||
|
VIDEO = 'video',
|
||||||
|
AUDIO = 'audio',
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum PostVisibility {
|
||||||
|
PUBLIC = 'public',
|
||||||
|
FOLLOWERS = 'followers',
|
||||||
|
PRIVATE = 'private',
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum TokenType {
|
||||||
|
ACCESS = 'access',
|
||||||
|
REFRESH = 'refresh',
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum UserRole {
|
||||||
|
USER = 'user',
|
||||||
|
ADMIN = 'admin',
|
||||||
|
SUPERADMIN = 'superadmin',
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const payload = request.user ?? {};
|
||||||
|
const userRoles: string[] = Array.isArray(payload.roles)
|
||||||
|
? payload.roles
|
||||||
|
: payload.role
|
||||||
|
? [payload.role]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return requiredRoles.some((role) => userRoles.includes(role));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SuperAdminJwtAuthGuard extends AuthGuard('superadmin-jwt') {}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { THROTTLE_META_KEY, ThrottleMeta } from '../decorators/throttle.decorator';
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ThrottleGuard implements CanActivate {
|
||||||
|
private readonly buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const meta = this.reflector.getAllAndOverride<ThrottleMeta>(THROTTLE_META_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (!meta) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest<Request & { ip?: string; originalUrl?: string }>();
|
||||||
|
const ip = req.ip ?? 'unknown';
|
||||||
|
const route = req.originalUrl ?? 'unknown-route';
|
||||||
|
const key = `${ip}:${route}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = this.buckets.get(key);
|
||||||
|
|
||||||
|
if (!existing || now > existing.resetAt) {
|
||||||
|
this.buckets.set(key, { count: 1, resetAt: now + meta.windowMs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.count >= meta.limit) {
|
||||||
|
throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.count += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseEnvelopeInterceptor implements NestInterceptor {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
const http = context.switchToHttp();
|
||||||
|
const response = http.getResponse<{ statusCode?: number }>();
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data) => ({
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
statusCode: response.statusCode ?? 200,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
role?: string;
|
||||||
|
tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh';
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { decodeOffsetCursor, encodeOffsetCursor } from './cursor.util';
|
||||||
|
|
||||||
|
describe('cursor util', () => {
|
||||||
|
it('encodes and decodes cursor offsets', () => {
|
||||||
|
const cursor = encodeOffsetCursor(40);
|
||||||
|
expect(decodeOffsetCursor(cursor)).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on invalid cursor', () => {
|
||||||
|
expect(decodeOffsetCursor('%%%invalid%%%')).toBeNull();
|
||||||
|
expect(decodeOffsetCursor(encodeOffsetCursor(-1))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export const encodeOffsetCursor = (offset: number): string =>
|
||||||
|
Buffer.from(String(offset), 'utf8').toString('base64url');
|
||||||
|
|
||||||
|
export const decodeOffsetCursor = (cursor?: string): number | null => {
|
||||||
|
if (!cursor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = Buffer.from(cursor, 'base64url').toString('utf8');
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
export const hashValue = async (value: string, saltRounds: number): Promise<string> =>
|
||||||
|
bcrypt.hash(value, saltRounds);
|
||||||
|
|
||||||
|
export const compareHash = async (value: string, hashedValue: string): Promise<boolean> =>
|
||||||
|
bcrypt.compare(value, hashedValue);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export default () => ({
|
||||||
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
|
port: Number(process.env.PORT ?? 4000),
|
||||||
|
host: process.env.HOST ?? '0.0.0.0',
|
||||||
|
publicBaseUrl:
|
||||||
|
process.env.PUBLIC_BASE_URL ??
|
||||||
|
`http://localhost:${Number(process.env.PORT ?? 4000)}`,
|
||||||
|
responseEnvelopeEnabled:
|
||||||
|
(process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||||
|
globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1',
|
||||||
|
cors: {
|
||||||
|
origins: (process.env.CORS_ORIGINS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter((origin) => origin.length > 0),
|
||||||
|
},
|
||||||
|
mongodb: {
|
||||||
|
uri: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/oudelaa',
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
accessSecret: process.env.JWT_ACCESS_SECRET ?? '',
|
||||||
|
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN ?? '15m',
|
||||||
|
refreshSecret: process.env.JWT_REFRESH_SECRET ?? '',
|
||||||
|
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '30d',
|
||||||
|
},
|
||||||
|
superAdmin: {
|
||||||
|
email: (process.env.SUPERADMIN_EMAIL ?? '').toLowerCase(),
|
||||||
|
password: process.env.SUPERADMIN_PASSWORD ?? '',
|
||||||
|
accessSecret: process.env.SUPERADMIN_ACCESS_SECRET ?? '',
|
||||||
|
accessExpiresIn: process.env.SUPERADMIN_ACCESS_EXPIRES_IN ?? '15m',
|
||||||
|
refreshSecret: process.env.SUPERADMIN_REFRESH_SECRET ?? '',
|
||||||
|
refreshExpiresIn: process.env.SUPERADMIN_REFRESH_EXPIRES_IN ?? '30d',
|
||||||
|
},
|
||||||
|
google: {
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||||
|
callbackUrl:
|
||||||
|
process.env.GOOGLE_CALLBACK_URL ?? 'http://localhost:4000/api/v1/auth/google/callback',
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
enabled: (process.env.EMAIL_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||||
|
smtpHost: process.env.EMAIL_SMTP_HOST ?? 'smtp.gmail.com',
|
||||||
|
smtpPort: Number(process.env.EMAIL_SMTP_PORT ?? 587),
|
||||||
|
smtpSecure: (process.env.EMAIL_SMTP_SECURE ?? 'false').toLowerCase() === 'true',
|
||||||
|
smtpUser: process.env.EMAIL_SMTP_USER ?? '',
|
||||||
|
smtpPass: process.env.EMAIL_SMTP_PASS ?? '',
|
||||||
|
fromName: process.env.EMAIL_FROM_NAME ?? 'Oudelaa',
|
||||||
|
fromEmail: process.env.EMAIL_FROM_EMAIL ?? process.env.EMAIL_SMTP_USER ?? '',
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
bcryptSaltRounds: Number(process.env.BCRYPT_SALT_ROUNDS ?? 12),
|
||||||
|
},
|
||||||
|
passwordReset: {
|
||||||
|
codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10),
|
||||||
|
maxAttempts: Number(process.env.PASSWORD_RESET_MAX_ATTEMPTS ?? 5),
|
||||||
|
tokenSecret: process.env.PASSWORD_RESET_TOKEN_SECRET ?? process.env.JWT_ACCESS_SECRET ?? '',
|
||||||
|
tokenExpiresIn: process.env.PASSWORD_RESET_TOKEN_EXPIRES_IN ?? '15m',
|
||||||
|
},
|
||||||
|
emailVerification: {
|
||||||
|
codeExpiresMinutes: Number(process.env.EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES ?? 10),
|
||||||
|
maxAttempts: Number(process.env.EMAIL_VERIFICATION_MAX_ATTEMPTS ?? 5),
|
||||||
|
},
|
||||||
|
swagger: {
|
||||||
|
title: process.env.SWAGGER_TITLE ?? 'Oudelaa API',
|
||||||
|
description: process.env.SWAGGER_DESCRIPTION ?? 'Social media backend API documentation',
|
||||||
|
version: process.env.SWAGGER_VERSION ?? '1.0.0',
|
||||||
|
path: process.env.SWAGGER_PATH ?? 'docs',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const APP_CONSTANTS = {
|
||||||
|
DEFAULT_PAGE: 1,
|
||||||
|
DEFAULT_LIMIT: 20,
|
||||||
|
MAX_LIMIT: 100,
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export const setupSwagger = (app: INestApplication, configService: ConfigService): void => {
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle(configService.get<string>('swagger.title', 'Oudelaa API'))
|
||||||
|
.setDescription(
|
||||||
|
configService.get<string>('swagger.description', 'Social media backend API documentation'),
|
||||||
|
)
|
||||||
|
.setVersion(configService.get<string>('swagger.version', '1.0.0'))
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup(configService.get<string>('swagger.path', 'docs'), app, document);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as Joi from 'joi';
|
||||||
|
|
||||||
|
export const validationSchema = Joi.object({
|
||||||
|
NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
|
||||||
|
PORT: Joi.number().default(4000),
|
||||||
|
HOST: Joi.string().default('0.0.0.0'),
|
||||||
|
PUBLIC_BASE_URL: Joi.string().uri().optional(),
|
||||||
|
RESPONSE_ENVELOPE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
GLOBAL_PREFIX: Joi.string().default('api/v1'),
|
||||||
|
CORS_ORIGINS: Joi.string().allow('').optional(),
|
||||||
|
MONGODB_URI: Joi.string().required(),
|
||||||
|
JWT_ACCESS_SECRET: Joi.string().min(16).required(),
|
||||||
|
JWT_ACCESS_EXPIRES_IN: Joi.string().default('15m'),
|
||||||
|
JWT_REFRESH_SECRET: Joi.string().min(16).required(),
|
||||||
|
JWT_REFRESH_EXPIRES_IN: Joi.string().default('30d'),
|
||||||
|
SUPERADMIN_EMAIL: Joi.string().email().required(),
|
||||||
|
SUPERADMIN_PASSWORD: Joi.string().min(8).required(),
|
||||||
|
SUPERADMIN_ACCESS_SECRET: Joi.string().min(16).required(),
|
||||||
|
SUPERADMIN_ACCESS_EXPIRES_IN: Joi.string().default('15m'),
|
||||||
|
SUPERADMIN_REFRESH_SECRET: Joi.string().min(16).required(),
|
||||||
|
SUPERADMIN_REFRESH_EXPIRES_IN: Joi.string().default('30d'),
|
||||||
|
GOOGLE_CLIENT_ID: Joi.string().allow('').optional(),
|
||||||
|
GOOGLE_CLIENT_SECRET: Joi.string().allow('').optional(),
|
||||||
|
GOOGLE_CALLBACK_URL: Joi.string().uri().optional(),
|
||||||
|
EMAIL_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
EMAIL_SMTP_HOST: Joi.string().allow('').optional(),
|
||||||
|
EMAIL_SMTP_PORT: Joi.number().default(587),
|
||||||
|
EMAIL_SMTP_SECURE: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
EMAIL_SMTP_USER: Joi.string().allow('').optional(),
|
||||||
|
EMAIL_SMTP_PASS: Joi.string().allow('').optional(),
|
||||||
|
EMAIL_FROM_NAME: Joi.string().default('Oudelaa'),
|
||||||
|
EMAIL_FROM_EMAIL: Joi.string().allow('').optional(),
|
||||||
|
BCRYPT_SALT_ROUNDS: Joi.number().min(8).max(15).default(12),
|
||||||
|
PASSWORD_RESET_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
|
||||||
|
PASSWORD_RESET_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
|
||||||
|
PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(),
|
||||||
|
PASSWORD_RESET_TOKEN_EXPIRES_IN: Joi.string().default('15m'),
|
||||||
|
EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
|
||||||
|
EMAIL_VERIFICATION_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
|
||||||
|
SWAGGER_TITLE: Joi.string().default('Oudelaa API'),
|
||||||
|
SWAGGER_DESCRIPTION: Joi.string().default('Social media backend API documentation'),
|
||||||
|
SWAGGER_VERSION: Joi.string().default('1.0.0'),
|
||||||
|
SWAGGER_PATH: Joi.string().default('docs'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { createMongooseOptions } from './mongoose-options.factory';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => createMongooseOptions(configService),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
exports: [MongooseModule],
|
||||||
|
})
|
||||||
|
export class DatabaseModule {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MongooseModuleOptions } from '@nestjs/mongoose';
|
||||||
|
|
||||||
|
export const createMongooseOptions = (configService: ConfigService): MongooseModuleOptions => ({
|
||||||
|
uri: configService.get<string>('mongodb.uri', { infer: true }),
|
||||||
|
autoIndex: true,
|
||||||
|
});
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import * as express from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
const configService = app.get(ConfigService);
|
||||||
|
const corsOrigins = configService.get<string[]>('cors.origins', []);
|
||||||
|
const uploadsDir = join(process.cwd(), 'uploads');
|
||||||
|
|
||||||
|
if (!existsSync(uploadsDir)) {
|
||||||
|
mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: corsOrigins.length ? corsOrigins : true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setGlobalPrefix(configService.get<string>('globalPrefix', 'api/v1'));
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const requestId = (req.headers['x-request-id'] as string | undefined) ?? randomUUID();
|
||||||
|
req.headers['x-request-id'] = requestId;
|
||||||
|
res.setHeader('x-request-id', requestId);
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const log = {
|
||||||
|
level: 'info',
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(log));
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseEnvelopeEnabled = configService.get<boolean>('responseEnvelopeEnabled', false);
|
||||||
|
if (responseEnvelopeEnabled) {
|
||||||
|
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use('/uploads', express.static(uploadsDir));
|
||||||
|
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle(configService.get<string>('swagger.title', 'Oudelaa API'))
|
||||||
|
.setDescription(
|
||||||
|
configService.get<string>('swagger.description', 'Social media backend API documentation'),
|
||||||
|
)
|
||||||
|
.setVersion(configService.get<string>('swagger.version', '1.0.0'))
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup(configService.get<string>('swagger.path', 'docs'), app, document);
|
||||||
|
|
||||||
|
const port = configService.get<number>('port', 4000);
|
||||||
|
const host = configService.get<string>('host', '0.0.0.0');
|
||||||
|
await app.listen(port, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { AuditRepository } from './audit.repository';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{
|
||||||
|
name: AuditLog.name,
|
||||||
|
schema: AuditLogSchema,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providers: [AuditRepository, AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { Model, Types } from 'mongoose';
|
||||||
|
import { AuditLog, AuditLogDocument } from './schemas/audit-log.schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditRepository {
|
||||||
|
constructor(@InjectModel(AuditLog.name) private readonly auditModel: Model<AuditLogDocument>) {}
|
||||||
|
|
||||||
|
async create(payload: {
|
||||||
|
actorType: 'user' | 'superadmin' | 'system';
|
||||||
|
actorUserId?: string;
|
||||||
|
actorIdentifier?: string;
|
||||||
|
action: string;
|
||||||
|
targetType: string;
|
||||||
|
targetId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.auditModel.create({
|
||||||
|
actorType: payload.actorType,
|
||||||
|
...(payload.actorUserId ? { actorUserId: new Types.ObjectId(payload.actorUserId) } : {}),
|
||||||
|
actorIdentifier: payload.actorIdentifier,
|
||||||
|
action: payload.action,
|
||||||
|
targetType: payload.targetType,
|
||||||
|
targetId: payload.targetId,
|
||||||
|
metadata: payload.metadata ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuditRepository } from './audit.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(private readonly auditRepository: AuditRepository) {}
|
||||||
|
|
||||||
|
async logSuperAdminAction(
|
||||||
|
actorIdentifier: string,
|
||||||
|
action: string,
|
||||||
|
targetType: string,
|
||||||
|
targetId?: string,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.auditRepository.create({
|
||||||
|
actorType: 'superadmin',
|
||||||
|
actorIdentifier,
|
||||||
|
action,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument, Types } from 'mongoose';
|
||||||
|
|
||||||
|
export type AuditLogDocument = HydratedDocument<AuditLog>;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
|
export class AuditLog {
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
actorType!: 'user' | 'superadmin' | 'system';
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, required: false, index: true })
|
||||||
|
actorUserId?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: false, index: true })
|
||||||
|
actorIdentifier?: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
action!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
targetType!: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: false, index: true })
|
||||||
|
targetId?: string;
|
||||||
|
|
||||||
|
@Prop({ type: Object, default: {} })
|
||||||
|
metadata!: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuditLogSchema = SchemaFactory.createForClass(AuditLog);
|
||||||
|
AuditLogSchema.index({ createdAt: -1, action: 1 });
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||||
|
import { GoogleTokenLoginDto } from './dto/google-token-login.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RegisterBasicDto } from './dto/register-basic.dto';
|
||||||
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
||||||
|
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
||||||
|
import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||||
|
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||||
|
|
||||||
|
@ApiTags('Auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@Throttle(10, 60_000)
|
||||||
|
async register(@Body() dto: RegisterDto) {
|
||||||
|
return this.authService.register(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('register-basic')
|
||||||
|
@Throttle(10, 60_000)
|
||||||
|
async registerBasic(@Body() dto: RegisterBasicDto) {
|
||||||
|
return this.authService.registerBasic(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('login')
|
||||||
|
@Throttle(20, 60_000)
|
||||||
|
async login(@Body() dto: LoginDto) {
|
||||||
|
return this.authService.login(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('refresh')
|
||||||
|
@Throttle(30, 60_000)
|
||||||
|
async refresh(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.authService.refresh(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('logout')
|
||||||
|
async logout(@Body() dto: RefreshTokenDto): Promise<{ message: string }> {
|
||||||
|
await this.authService.logout(dto);
|
||||||
|
return { message: 'Logged out successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('forgot-password')
|
||||||
|
@Throttle(8, 60_000)
|
||||||
|
async forgotPassword(@Body() dto: ForgotPasswordDto) {
|
||||||
|
return this.authService.forgotPassword(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('verify-reset-code')
|
||||||
|
@Throttle(20, 60_000)
|
||||||
|
async verifyResetCode(@Body() dto: VerifyResetCodeDto) {
|
||||||
|
return this.authService.verifyResetCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('reset-password')
|
||||||
|
@Throttle(10, 60_000)
|
||||||
|
async resetPassword(@Body() dto: ResetPasswordDto) {
|
||||||
|
return this.authService.resetPassword(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('send-email-verification')
|
||||||
|
@Throttle(8, 60_000)
|
||||||
|
async sendEmailVerification(@Body() dto: SendEmailVerificationDto) {
|
||||||
|
return this.authService.sendEmailVerification(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('verify-email')
|
||||||
|
@Throttle(20, 60_000)
|
||||||
|
async verifyEmail(@Body() dto: VerifyEmailDto) {
|
||||||
|
return this.authService.verifyEmail(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('superadmin/login')
|
||||||
|
@Throttle(10, 60_000)
|
||||||
|
async superAdminLogin(@Body() dto: SuperAdminLoginDto) {
|
||||||
|
return this.authService.superAdminLogin(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('superadmin/refresh')
|
||||||
|
@Throttle(20, 60_000)
|
||||||
|
async superAdminRefresh(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.authService.superAdminRefresh(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('superadmin/logout')
|
||||||
|
async superAdminLogout(@Body() dto: RefreshTokenDto): Promise<{ message: string }> {
|
||||||
|
await this.authService.superAdminLogout(dto);
|
||||||
|
return { message: 'Superadmin logged out successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('google')
|
||||||
|
@UseGuards(GoogleAuthGuard)
|
||||||
|
async googleAuth(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('google/callback')
|
||||||
|
@UseGuards(GoogleAuthGuard)
|
||||||
|
async googleCallback(
|
||||||
|
@Req()
|
||||||
|
req: Request & {
|
||||||
|
user: { googleId: string; email: string; name: string; avatar?: string };
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.authService.loginWithGoogle(req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('google/token')
|
||||||
|
@Throttle(20, 60_000)
|
||||||
|
async googleTokenLogin(@Body() dto: GoogleTokenLoginDto) {
|
||||||
|
return this.authService.loginWithGoogleIdToken(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('sessions')
|
||||||
|
async listMySessions(@CurrentUser() user: JwtPayload) {
|
||||||
|
return this.authService.listUserSessions(user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('sessions/:jti/revoke')
|
||||||
|
async revokeSession(@CurrentUser() user: JwtPayload, @Param('jti') jti: string) {
|
||||||
|
await this.authService.revokeUserSession(user.sub, jti);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { EmailModule } from '../email/email.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthRepository } from './auth.repository';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
||||||
|
import {
|
||||||
|
EmailVerificationCode,
|
||||||
|
EmailVerificationCodeSchema,
|
||||||
|
} from './schemas/email-verification-code.schema';
|
||||||
|
import {
|
||||||
|
PasswordResetCode,
|
||||||
|
PasswordResetCodeSchema,
|
||||||
|
} from './schemas/password-reset-code.schema';
|
||||||
|
import { RefreshToken, RefreshTokenSchema } from './schemas/refresh-token.schema';
|
||||||
|
import {
|
||||||
|
SuperAdminRefreshToken,
|
||||||
|
SuperAdminRefreshTokenSchema,
|
||||||
|
} from './schemas/super-admin-refresh-token.schema';
|
||||||
|
import { GoogleStrategy } from './strategies/google.strategy';
|
||||||
|
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { SuperAdminJwtStrategy } from './strategies/super-admin-jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.get<string>('jwt.accessSecret', { infer: true }),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: configService.get<string>('jwt.accessExpiresIn', { infer: true }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{
|
||||||
|
name: RefreshToken.name,
|
||||||
|
schema: RefreshTokenSchema,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: SuperAdminRefreshToken.name,
|
||||||
|
schema: SuperAdminRefreshTokenSchema,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: PasswordResetCode.name,
|
||||||
|
schema: PasswordResetCodeSchema,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: EmailVerificationCode.name,
|
||||||
|
schema: EmailVerificationCodeSchema,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
EmailModule,
|
||||||
|
UsersModule,
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
AuthRepository,
|
||||||
|
JwtStrategy,
|
||||||
|
JwtRefreshStrategy,
|
||||||
|
GoogleStrategy,
|
||||||
|
GoogleAuthGuard,
|
||||||
|
SuperAdminJwtStrategy,
|
||||||
|
],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { Model, Types } from 'mongoose';
|
||||||
|
import {
|
||||||
|
EmailVerificationCode,
|
||||||
|
EmailVerificationCodeDocument,
|
||||||
|
} from './schemas/email-verification-code.schema';
|
||||||
|
import {
|
||||||
|
PasswordResetCode,
|
||||||
|
PasswordResetCodeDocument,
|
||||||
|
} from './schemas/password-reset-code.schema';
|
||||||
|
import { RefreshToken, RefreshTokenDocument } from './schemas/refresh-token.schema';
|
||||||
|
import {
|
||||||
|
SuperAdminRefreshToken,
|
||||||
|
SuperAdminRefreshTokenDocument,
|
||||||
|
} from './schemas/super-admin-refresh-token.schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(RefreshToken.name)
|
||||||
|
private readonly refreshTokenModel: Model<RefreshTokenDocument>,
|
||||||
|
@InjectModel(SuperAdminRefreshToken.name)
|
||||||
|
private readonly superAdminRefreshTokenModel: Model<SuperAdminRefreshTokenDocument>,
|
||||||
|
@InjectModel(PasswordResetCode.name)
|
||||||
|
private readonly passwordResetCodeModel: Model<PasswordResetCodeDocument>,
|
||||||
|
@InjectModel(EmailVerificationCode.name)
|
||||||
|
private readonly emailVerificationCodeModel: Model<EmailVerificationCodeDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createRefreshToken(userId: string, jti: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
||||||
|
await this.refreshTokenModel.create({
|
||||||
|
userId: new Types.ObjectId(userId),
|
||||||
|
jti,
|
||||||
|
tokenHash,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveUserTokens(userId: string): Promise<RefreshTokenDocument[]> {
|
||||||
|
return this.refreshTokenModel
|
||||||
|
.find({ userId: new Types.ObjectId(userId), revoked: false })
|
||||||
|
.select('+tokenHash')
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAllUserTokens(userId: string): Promise<void> {
|
||||||
|
await this.refreshTokenModel
|
||||||
|
.updateMany({ userId: new Types.ObjectId(userId), revoked: false }, { revoked: true })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeUserTokenByJti(userId: string, jti: string): Promise<void> {
|
||||||
|
await this.refreshTokenModel
|
||||||
|
.updateOne({ userId: new Types.ObjectId(userId), jti, revoked: false }, { revoked: true })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveUserTokenByJti(userId: string, jti: string): Promise<RefreshTokenDocument | null> {
|
||||||
|
return this.refreshTokenModel
|
||||||
|
.findOne({ userId: new Types.ObjectId(userId), jti, revoked: false })
|
||||||
|
.select('+tokenHash')
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markCompromisedAndRevokeAll(userId: string): Promise<void> {
|
||||||
|
await this.refreshTokenModel
|
||||||
|
.updateMany(
|
||||||
|
{ userId: new Types.ObjectId(userId), revoked: false },
|
||||||
|
{ revoked: true, compromised: true },
|
||||||
|
)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUserSessions(userId: string): Promise<RefreshTokenDocument[]> {
|
||||||
|
return this.refreshTokenModel
|
||||||
|
.find({ userId: new Types.ObjectId(userId), revoked: false, expiresAt: { $gt: new Date() } })
|
||||||
|
.select('jti expiresAt createdAt')
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeExpiredAndRevoked(userId: string): Promise<void> {
|
||||||
|
await this.refreshTokenModel
|
||||||
|
.deleteMany({
|
||||||
|
userId: new Types.ObjectId(userId),
|
||||||
|
$or: [{ revoked: true }, { expiresAt: { $lt: new Date() } }],
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSuperAdminRefreshToken(
|
||||||
|
adminEmail: string,
|
||||||
|
tokenHash: string,
|
||||||
|
expiresAt: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.superAdminRefreshTokenModel.create({
|
||||||
|
adminEmail: adminEmail.toLowerCase(),
|
||||||
|
tokenHash,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveSuperAdminTokens(adminEmail: string): Promise<SuperAdminRefreshTokenDocument[]> {
|
||||||
|
return this.superAdminRefreshTokenModel
|
||||||
|
.find({ adminEmail: adminEmail.toLowerCase(), revoked: false })
|
||||||
|
.select('+tokenHash')
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAllSuperAdminTokens(adminEmail: string): Promise<void> {
|
||||||
|
await this.superAdminRefreshTokenModel
|
||||||
|
.updateMany({ adminEmail: adminEmail.toLowerCase(), revoked: false }, { revoked: true })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> {
|
||||||
|
await this.superAdminRefreshTokenModel
|
||||||
|
.deleteMany({
|
||||||
|
adminEmail: adminEmail.toLowerCase(),
|
||||||
|
$or: [{ revoked: true }, { expiresAt: { $lt: new Date() } }],
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateActivePasswordResetCodes(userId: string): Promise<void> {
|
||||||
|
await this.passwordResetCodeModel
|
||||||
|
.updateMany(
|
||||||
|
{ userId: new Types.ObjectId(userId), used: false, expiresAt: { $gt: new Date() } },
|
||||||
|
{ used: true },
|
||||||
|
)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPasswordResetCode(userId: string, codeHash: string, expiresAt: Date): Promise<void> {
|
||||||
|
await this.passwordResetCodeModel.create({
|
||||||
|
userId: new Types.ObjectId(userId),
|
||||||
|
codeHash,
|
||||||
|
expiresAt,
|
||||||
|
attempts: 0,
|
||||||
|
verified: false,
|
||||||
|
used: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLatestActivePasswordResetCode(userId: string): Promise<PasswordResetCodeDocument | null> {
|
||||||
|
return this.passwordResetCodeModel
|
||||||
|
.findOne({
|
||||||
|
userId: new Types.ObjectId(userId),
|
||||||
|
used: false,
|
||||||
|
expiresAt: { $gt: new Date() },
|
||||||
|
})
|
||||||
|
.select('+codeHash')
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementPasswordResetAttempts(id: string): Promise<void> {
|
||||||
|
await this.passwordResetCodeModel.findByIdAndUpdate(id, { $inc: { attempts: 1 } }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markPasswordResetCodeVerified(id: string): Promise<void> {
|
||||||
|
await this.passwordResetCodeModel.findByIdAndUpdate(id, { verified: true }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markPasswordResetCodeUsed(id: string): Promise<void> {
|
||||||
|
await this.passwordResetCodeModel.findByIdAndUpdate(id, { used: true }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markPasswordResetCodeUsedByUser(userId: string): Promise<void> {
|
||||||
|
await this.passwordResetCodeModel
|
||||||
|
.updateMany({ userId: new Types.ObjectId(userId), used: false }, { used: true })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findValidVerifiedPasswordResetCode(
|
||||||
|
codeId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PasswordResetCodeDocument | null> {
|
||||||
|
return this.passwordResetCodeModel
|
||||||
|
.findOne({
|
||||||
|
_id: new Types.ObjectId(codeId),
|
||||||
|
userId: new Types.ObjectId(userId),
|
||||||
|
used: false,
|
||||||
|
verified: true,
|
||||||
|
expiresAt: { $gt: new Date() },
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateActiveEmailVerificationCodes(userId: string): Promise<void> {
|
||||||
|
await this.emailVerificationCodeModel
|
||||||
|
.updateMany(
|
||||||
|
{ userId: new Types.ObjectId(userId), used: false, expiresAt: { $gt: new Date() } },
|
||||||
|
{ used: true },
|
||||||
|
)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEmailVerificationCode(userId: string, codeHash: string, expiresAt: Date): Promise<void> {
|
||||||
|
await this.emailVerificationCodeModel.create({
|
||||||
|
userId: new Types.ObjectId(userId),
|
||||||
|
codeHash,
|
||||||
|
expiresAt,
|
||||||
|
attempts: 0,
|
||||||
|
used: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLatestActiveEmailVerificationCode(
|
||||||
|
userId: string,
|
||||||
|
): Promise<EmailVerificationCodeDocument | null> {
|
||||||
|
return this.emailVerificationCodeModel
|
||||||
|
.findOne({
|
||||||
|
userId: new Types.ObjectId(userId),
|
||||||
|
used: false,
|
||||||
|
expiresAt: { $gt: new Date() },
|
||||||
|
})
|
||||||
|
.select('+codeHash')
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementEmailVerificationAttempts(id: string): Promise<void> {
|
||||||
|
await this.emailVerificationCodeModel.findByIdAndUpdate(id, { $inc: { attempts: 1 } }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markEmailVerificationCodeUsed(id: string): Promise<void> {
|
||||||
|
await this.emailVerificationCodeModel.findByIdAndUpdate(id, { used: true }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllEmailVerificationCodesUsedByUser(userId: string): Promise<void> {
|
||||||
|
await this.emailVerificationCodeModel
|
||||||
|
.updateMany({ userId: new Types.ObjectId(userId), used: false }, { used: true })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { randomBytes, randomInt, randomUUID } from 'crypto';
|
||||||
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
|
import { compareHash, hashValue } from '../../common/utils/hash.util';
|
||||||
|
import { EmailService } from '../email/email.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||||
|
import { GoogleTokenLoginDto } from './dto/google-token-login.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RegisterBasicDto } from './dto/register-basic.dto';
|
||||||
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
||||||
|
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
||||||
|
import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||||
|
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||||
|
import { AuthRepository } from './auth.repository';
|
||||||
|
import { AuthResult, TokenPair } from './types/token-pair.type';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
private readonly googleOAuthClient = new OAuth2Client();
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly authRepository: AuthRepository,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly emailService: EmailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(dto: RegisterDto): Promise<{ message: string; email: string; debugCode?: string }> {
|
||||||
|
if (dto.password !== dto.confirmPassword) {
|
||||||
|
throw new BadRequestException('Password confirmation does not match');
|
||||||
|
}
|
||||||
|
|
||||||
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
const passwordHash = await hashValue(dto.password, saltRounds);
|
||||||
|
const generatedUsername = dto.username ?? (await this.generateUniqueUsernameFromEmail(dto.email));
|
||||||
|
const resolvedName = dto.name ?? dto.stageName ?? generatedUsername;
|
||||||
|
|
||||||
|
const { confirmPassword: _, ...registerPayload } = dto;
|
||||||
|
const user = await this.usersService.create({
|
||||||
|
...registerPayload,
|
||||||
|
name: resolvedName,
|
||||||
|
username: generatedUsername,
|
||||||
|
password: passwordHash,
|
||||||
|
});
|
||||||
|
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
||||||
|
const response: { message: string; email: string; debugCode?: string } = {
|
||||||
|
message: 'Registration successful. Verify your email with the code sent.',
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||||
|
if (nodeEnv !== 'production') {
|
||||||
|
response.debugCode = code;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> {
|
||||||
|
if (dto.password !== dto.confirmPassword) {
|
||||||
|
throw new BadRequestException('Password confirmation does not match');
|
||||||
|
}
|
||||||
|
|
||||||
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
const passwordHash = await hashValue(dto.password, saltRounds);
|
||||||
|
const generatedUsername = await this.generateUniqueUsernameFromEmail(dto.email);
|
||||||
|
|
||||||
|
const user = await this.usersService.create({
|
||||||
|
name: generatedUsername,
|
||||||
|
username: generatedUsername,
|
||||||
|
email: dto.email,
|
||||||
|
password: passwordHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
||||||
|
const response: { message: string; email: string; debugCode?: string } = {
|
||||||
|
message: 'Registration successful. Verify your email with the code sent.',
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||||
|
if (nodeEnv !== 'production') {
|
||||||
|
response.debugCode = code;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(dto: LoginDto): Promise<AuthResult> {
|
||||||
|
const user = await this.usersService.findByEmailWithPassword(dto.email);
|
||||||
|
if (!user || !user.password) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
if (user.isDisabled) {
|
||||||
|
throw new ForbiddenException('Account is disabled');
|
||||||
|
}
|
||||||
|
if (!user.isVerified) {
|
||||||
|
throw new ForbiddenException('Email not verified');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await compareHash(dto.password, user.password);
|
||||||
|
if (!isMatch) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
||||||
|
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||||
|
return { ...tokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEmailVerification(
|
||||||
|
dto: SendEmailVerificationDto,
|
||||||
|
): Promise<{ message: string; debugCode?: string }> {
|
||||||
|
const normalizedEmail = dto.email.toLowerCase();
|
||||||
|
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||||
|
const message = 'If this email exists, a verification code was sent';
|
||||||
|
if (!user || user.isDisabled) {
|
||||||
|
return { message };
|
||||||
|
}
|
||||||
|
if (user.isVerified) {
|
||||||
|
return { message: 'Email is already verified' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
||||||
|
const response: { message: string; debugCode?: string } = { message };
|
||||||
|
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||||
|
if (nodeEnv !== 'production') {
|
||||||
|
response.debugCode = code;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyEmail(dto: VerifyEmailDto): Promise<AuthResult & { message: string }> {
|
||||||
|
const normalizedEmail = dto.email.toLowerCase();
|
||||||
|
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||||
|
if (!user || user.isDisabled) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired verification code');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isVerified) {
|
||||||
|
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
||||||
|
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||||
|
return {
|
||||||
|
message: 'Email already verified',
|
||||||
|
...tokens,
|
||||||
|
user: safeUser.toObject() as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeRecord = await this.authRepository.findLatestActiveEmailVerificationCode(user.id);
|
||||||
|
if (!codeRecord) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired verification code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAttempts = this.configService.get<number>('emailVerification.maxAttempts', { infer: true });
|
||||||
|
if (codeRecord.attempts >= maxAttempts) {
|
||||||
|
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
||||||
|
throw new UnauthorizedException('Verification code attempts exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await compareHash(dto.code, codeRecord.codeHash);
|
||||||
|
if (!isMatch) {
|
||||||
|
await this.authRepository.incrementEmailVerificationAttempts(codeRecord.id);
|
||||||
|
if (codeRecord.attempts + 1 >= maxAttempts) {
|
||||||
|
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException('Invalid or expired verification code');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.usersService.markEmailVerified(user.id);
|
||||||
|
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
||||||
|
await this.authRepository.markAllEmailVerificationCodesUsedByUser(user.id);
|
||||||
|
|
||||||
|
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||||
|
const tokens = await this.generateAndStoreTokenPair(safeUser.id, safeUser.username, safeUser.role ?? 'user');
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Email verified successfully',
|
||||||
|
...tokens,
|
||||||
|
user: safeUser.toObject() as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(dto: RefreshTokenDto): Promise<AuthResult> {
|
||||||
|
const decoded = this.jwtService.verify<{ sub: string; username: string; tokenType: string; jti?: string }>(
|
||||||
|
dto.refreshToken,
|
||||||
|
{
|
||||||
|
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (decoded.tokenType !== 'refresh' || !decoded.jti) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenRecord = await this.authRepository.findActiveUserTokenByJti(decoded.sub, decoded.jti);
|
||||||
|
if (!tokenRecord) {
|
||||||
|
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
|
||||||
|
throw new UnauthorizedException('Refresh token reuse detected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await compareHash(dto.refreshToken, tokenRecord.tokenHash);
|
||||||
|
if (!isMatch) {
|
||||||
|
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
|
||||||
|
throw new UnauthorizedException('Refresh token reuse detected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeUser = await this.usersService.findByIdOrFail(decoded.sub);
|
||||||
|
if (safeUser.isDisabled) {
|
||||||
|
throw new ForbiddenException('Account is disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.authRepository.revokeUserTokenByJti(decoded.sub, decoded.jti);
|
||||||
|
const authTokens = await this.generateAndStoreTokenPair(
|
||||||
|
decoded.sub,
|
||||||
|
safeUser.username,
|
||||||
|
safeUser.role,
|
||||||
|
);
|
||||||
|
return { ...authTokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(dto: RefreshTokenDto): Promise<void> {
|
||||||
|
try {
|
||||||
|
const decoded = this.jwtService.verify<{ sub: string }>(dto.refreshToken, {
|
||||||
|
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
|
||||||
|
});
|
||||||
|
await this.authRepository.revokeAllUserTokens(decoded.sub);
|
||||||
|
await this.authRepository.removeExpiredAndRevoked(decoded.sub);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithGoogle(googleUser: {
|
||||||
|
googleId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatar?: string;
|
||||||
|
}): Promise<AuthResult> {
|
||||||
|
let user = await this.usersService.findByGoogleId(googleUser.googleId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await this.usersService.findByEmail(googleUser.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const generatedUsername = await this.generateUniqueUsernameFromEmail(googleUser.email);
|
||||||
|
const randomPassword = randomBytes(24).toString('hex');
|
||||||
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
const passwordHash = await hashValue(randomPassword, saltRounds);
|
||||||
|
|
||||||
|
user = await this.usersService.create({
|
||||||
|
name: googleUser.name,
|
||||||
|
username: generatedUsername,
|
||||||
|
email: googleUser.email,
|
||||||
|
password: passwordHash,
|
||||||
|
avatar: googleUser.avatar ?? '',
|
||||||
|
isVerified: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.googleId) {
|
||||||
|
user = await this.usersService.linkGoogleAccount(user.id, googleUser.googleId, googleUser.avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isDisabled) {
|
||||||
|
throw new ForbiddenException('Account is disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
||||||
|
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||||
|
return { ...tokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithGoogleIdToken(dto: GoogleTokenLoginDto): Promise<AuthResult> {
|
||||||
|
const clientId = this.configService.get<string>('google.clientId', { infer: true });
|
||||||
|
if (!clientId) {
|
||||||
|
throw new BadRequestException('Google client is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload:
|
||||||
|
| {
|
||||||
|
sub?: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
try {
|
||||||
|
const ticket = await this.googleOAuthClient.verifyIdToken({
|
||||||
|
idToken: dto.idToken,
|
||||||
|
audience: clientId,
|
||||||
|
});
|
||||||
|
payload = ticket.getPayload();
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid Google id token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload?.sub || !payload.email || payload.email_verified !== true) {
|
||||||
|
throw new UnauthorizedException('Google account data is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loginWithGoogle({
|
||||||
|
googleId: payload.sub,
|
||||||
|
email: payload.email.toLowerCase(),
|
||||||
|
name: payload.name ?? payload.email.split('@')[0],
|
||||||
|
avatar: payload.picture,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async superAdminLogin(dto: SuperAdminLoginDto): Promise<{
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
superAdmin: { email: string };
|
||||||
|
}> {
|
||||||
|
const configuredEmail = this.configService.get<string>('superAdmin.email', { infer: true });
|
||||||
|
const configuredPassword = this.configService.get<string>('superAdmin.password', { infer: true });
|
||||||
|
|
||||||
|
if (
|
||||||
|
!configuredEmail ||
|
||||||
|
!configuredPassword ||
|
||||||
|
dto.email.toLowerCase() !== configuredEmail.toLowerCase() ||
|
||||||
|
dto.password !== configuredPassword
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException('Invalid superadmin credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.generateAndStoreSuperAdminTokenPair(configuredEmail);
|
||||||
|
return { ...tokens, superAdmin: { email: configuredEmail } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async superAdminRefresh(dto: RefreshTokenDto): Promise<{
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
superAdmin: { email: string };
|
||||||
|
}> {
|
||||||
|
const decoded = this.jwtService.verify<{ email: string; tokenType: string }>(dto.refreshToken, {
|
||||||
|
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email) {
|
||||||
|
throw new UnauthorizedException('Invalid superadmin refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTokens = await this.authRepository.findActiveSuperAdminTokens(decoded.email);
|
||||||
|
if (!activeTokens.length) {
|
||||||
|
throw new UnauthorizedException('Invalid superadmin refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
let validTokenFound = false;
|
||||||
|
for (const token of activeTokens) {
|
||||||
|
const isMatch = await compareHash(dto.refreshToken, token.tokenHash);
|
||||||
|
if (isMatch) {
|
||||||
|
validTokenFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validTokenFound) {
|
||||||
|
throw new UnauthorizedException('Invalid superadmin refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||||
|
const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email);
|
||||||
|
return { ...tokens, superAdmin: { email: decoded.email } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async superAdminLogout(dto: RefreshTokenDto): Promise<void> {
|
||||||
|
try {
|
||||||
|
const decoded = this.jwtService.verify<{ email: string }>(dto.refreshToken, {
|
||||||
|
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||||
|
});
|
||||||
|
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||||
|
await this.authRepository.removeExpiredAndRevokedSuperAdmin(decoded.email);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Invalid superadmin refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUserSessions(userId: string): Promise<{ items: Array<{ jti: string; createdAt: Date; expiresAt: Date }> }> {
|
||||||
|
const sessions = await this.authRepository.listUserSessions(userId);
|
||||||
|
return {
|
||||||
|
items: sessions.map((s) => ({
|
||||||
|
jti: s.jti,
|
||||||
|
createdAt: (s as unknown as { createdAt: Date }).createdAt,
|
||||||
|
expiresAt: s.expiresAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeUserSession(userId: string, jti: string): Promise<void> {
|
||||||
|
await this.authRepository.revokeUserTokenByJti(userId, jti);
|
||||||
|
}
|
||||||
|
|
||||||
|
async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string; debugCode?: string }> {
|
||||||
|
const normalizedEmail = dto.email.toLowerCase();
|
||||||
|
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||||
|
const message = 'If this email exists, a reset code was sent';
|
||||||
|
|
||||||
|
if (!user || user.isDisabled) {
|
||||||
|
return { message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = this.generateResetCode();
|
||||||
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
const codeHash = await hashValue(code, saltRounds);
|
||||||
|
const expiresMinutes = this.configService.get<number>('passwordReset.codeExpiresMinutes', {
|
||||||
|
infer: true,
|
||||||
|
});
|
||||||
|
const expiresAt = new Date(Date.now() + expiresMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
await this.authRepository.invalidateActivePasswordResetCodes(user.id);
|
||||||
|
await this.authRepository.createPasswordResetCode(user.id, codeHash, expiresAt);
|
||||||
|
|
||||||
|
await this.emailService.sendPasswordResetCode(normalizedEmail, code, expiresMinutes);
|
||||||
|
this.logger.log(`Password reset code generated for ${normalizedEmail}`);
|
||||||
|
|
||||||
|
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||||
|
if (nodeEnv !== 'production') {
|
||||||
|
return { message, debugCode: code };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message };
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyResetCode(
|
||||||
|
dto: VerifyResetCodeDto,
|
||||||
|
): Promise<{ resetToken: string; expiresIn: string }> {
|
||||||
|
const normalizedEmail = dto.email.toLowerCase();
|
||||||
|
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||||
|
if (!user || user.isDisabled) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired reset code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeRecord = await this.authRepository.findLatestActivePasswordResetCode(user.id);
|
||||||
|
if (!codeRecord) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired reset code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAttempts = this.configService.get<number>('passwordReset.maxAttempts', { infer: true });
|
||||||
|
if (codeRecord.attempts >= maxAttempts) {
|
||||||
|
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
|
||||||
|
throw new UnauthorizedException('Reset code attempts exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await compareHash(dto.code, codeRecord.codeHash);
|
||||||
|
if (!isMatch) {
|
||||||
|
await this.authRepository.incrementPasswordResetAttempts(codeRecord.id);
|
||||||
|
const attemptsAfter = codeRecord.attempts + 1;
|
||||||
|
if (attemptsAfter >= maxAttempts) {
|
||||||
|
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException('Invalid or expired reset code');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.authRepository.markPasswordResetCodeVerified(codeRecord.id);
|
||||||
|
const resetTokenExpiresIn =
|
||||||
|
this.configService.get<string>('passwordReset.tokenExpiresIn', {
|
||||||
|
infer: true,
|
||||||
|
}) ?? '15m';
|
||||||
|
const resetToken = await this.jwtService.signAsync(
|
||||||
|
{ sub: user.id, tokenType: 'password_reset', prcId: codeRecord.id },
|
||||||
|
{
|
||||||
|
secret: this.configService.get<string>('passwordReset.tokenSecret', { infer: true }),
|
||||||
|
expiresIn: resetTokenExpiresIn,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
resetToken,
|
||||||
|
expiresIn: resetTokenExpiresIn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(dto: ResetPasswordDto): Promise<{ message: string }> {
|
||||||
|
if (dto.newPassword !== dto.confirmPassword) {
|
||||||
|
throw new BadRequestException('Password confirmation does not match');
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded: { sub: string; tokenType: string; prcId: string };
|
||||||
|
try {
|
||||||
|
decoded = this.jwtService.verify(dto.resetToken, {
|
||||||
|
secret: this.configService.get<string>('passwordReset.tokenSecret', { infer: true }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid or expired reset token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded.tokenType !== 'password_reset' || !decoded.prcId || !decoded.sub) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired reset token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeRecord = await this.authRepository.findValidVerifiedPasswordResetCode(
|
||||||
|
decoded.prcId,
|
||||||
|
decoded.sub,
|
||||||
|
);
|
||||||
|
if (!codeRecord) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired reset token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.usersService.findByIdOrFail(decoded.sub);
|
||||||
|
if (user.isDisabled) {
|
||||||
|
throw new ForbiddenException('Account is disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
const passwordHash = await hashValue(dto.newPassword, saltRounds);
|
||||||
|
await this.usersService.updatePassword(decoded.sub, passwordHash);
|
||||||
|
|
||||||
|
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
|
||||||
|
await this.authRepository.markPasswordResetCodeUsedByUser(decoded.sub);
|
||||||
|
await this.authRepository.revokeAllUserTokens(decoded.sub);
|
||||||
|
await this.authRepository.removeExpiredAndRevoked(decoded.sub);
|
||||||
|
|
||||||
|
return { message: 'Password reset successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateAndStoreTokenPair(
|
||||||
|
userId: string,
|
||||||
|
username: string,
|
||||||
|
role: string,
|
||||||
|
): Promise<TokenPair> {
|
||||||
|
const refreshJti = randomUUID();
|
||||||
|
const [accessToken, refreshToken] = await Promise.all([
|
||||||
|
this.jwtService.signAsync(
|
||||||
|
{ sub: userId, username, role, tokenType: 'access' },
|
||||||
|
{
|
||||||
|
secret: this.configService.get<string>('jwt.accessSecret', { infer: true }),
|
||||||
|
expiresIn: this.configService.get<string>('jwt.accessExpiresIn', { infer: true }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this.jwtService.signAsync(
|
||||||
|
{ sub: userId, username, role, tokenType: 'refresh', jti: refreshJti },
|
||||||
|
{
|
||||||
|
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
|
||||||
|
expiresIn: this.configService.get<string>('jwt.refreshExpiresIn', { infer: true }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
const tokenHash = await hashValue(refreshToken, saltRounds);
|
||||||
|
|
||||||
|
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn', {
|
||||||
|
infer: true,
|
||||||
|
});
|
||||||
|
const refreshExpiresInMs = this.parseExpiresInToMs(refreshExpiresIn ?? '30d');
|
||||||
|
|
||||||
|
await this.authRepository.createRefreshToken(userId, refreshJti, tokenHash, new Date(Date.now() + refreshExpiresInMs));
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateAndStoreSuperAdminTokenPair(adminEmail: string): Promise<TokenPair> {
|
||||||
|
const [accessToken, refreshToken] = await Promise.all([
|
||||||
|
this.jwtService.signAsync(
|
||||||
|
{
|
||||||
|
sub: 'superadmin',
|
||||||
|
username: 'superadmin',
|
||||||
|
email: adminEmail.toLowerCase(),
|
||||||
|
role: 'superadmin',
|
||||||
|
tokenType: 'superadmin_access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }),
|
||||||
|
expiresIn: this.configService.get<string>('superAdmin.accessExpiresIn', { infer: true }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this.jwtService.signAsync(
|
||||||
|
{
|
||||||
|
sub: 'superadmin',
|
||||||
|
username: 'superadmin',
|
||||||
|
email: adminEmail.toLowerCase(),
|
||||||
|
role: 'superadmin',
|
||||||
|
tokenType: 'superadmin_refresh',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||||
|
expiresIn: this.configService.get<string>('superAdmin.refreshExpiresIn', { infer: true }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
const tokenHash = await hashValue(refreshToken, saltRounds);
|
||||||
|
const refreshExpiresIn = this.configService.get<string>('superAdmin.refreshExpiresIn', {
|
||||||
|
infer: true,
|
||||||
|
});
|
||||||
|
const refreshExpiresInMs = this.parseExpiresInToMs(refreshExpiresIn ?? '30d');
|
||||||
|
|
||||||
|
await this.authRepository.createSuperAdminRefreshToken(
|
||||||
|
adminEmail,
|
||||||
|
tokenHash,
|
||||||
|
new Date(Date.now() + refreshExpiresInMs),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseExpiresInToMs(expiresIn: string): number {
|
||||||
|
const regex = /^(\d+)([smhd])$/;
|
||||||
|
const match = expiresIn.match(regex);
|
||||||
|
if (!match) {
|
||||||
|
return 30 * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(match[1]);
|
||||||
|
const unit = match[2];
|
||||||
|
|
||||||
|
const multipliers: Record<string, number> = {
|
||||||
|
s: 1000,
|
||||||
|
m: 60 * 1000,
|
||||||
|
h: 60 * 60 * 1000,
|
||||||
|
d: 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
return value * multipliers[unit];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateUniqueUsernameFromEmail(email: string): Promise<string> {
|
||||||
|
const base = email.split('@')[0].replace(/[^a-zA-Z0-9_.]/g, '').toLowerCase() || 'user';
|
||||||
|
let candidate = base.slice(0, 24);
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
while (await this.usersService.findByUsername(candidate)) {
|
||||||
|
const suffix = `_${counter}`;
|
||||||
|
const maxBaseLength = 30 - suffix.length;
|
||||||
|
candidate = `${base.slice(0, Math.max(1, maxBaseLength))}${suffix}`;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateResetCode(): string {
|
||||||
|
return String(randomInt(100000, 1000000));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueEmailVerificationCode(userId: string, email: string): Promise<string> {
|
||||||
|
const code = this.generateResetCode();
|
||||||
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
const codeHash = await hashValue(code, saltRounds);
|
||||||
|
const expiresMinutes = this.configService.get<number>('emailVerification.codeExpiresMinutes', {
|
||||||
|
infer: true,
|
||||||
|
});
|
||||||
|
const expiresAt = new Date(Date.now() + expiresMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
await this.authRepository.invalidateActiveEmailVerificationCodes(userId);
|
||||||
|
await this.authRepository.createEmailVerificationCode(userId, codeHash, expiresAt);
|
||||||
|
|
||||||
|
await this.emailService.sendVerificationCode(email, code, expiresMinutes);
|
||||||
|
this.logger.log(`Email verification code generated for ${email}`);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class AuthResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
accessToken!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
refreshToken!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Full user profile without password',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
})
|
||||||
|
user!: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail } from 'class-validator';
|
||||||
|
|
||||||
|
export class ForgotPasswordDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class GoogleTokenLoginDto {
|
||||||
|
@ApiProperty({ description: 'Google ID token from frontend Google Sign-In' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
idToken!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ example: 'john@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 8 })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 64)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterBasicDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 8, example: 'StrongPass123!' })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 64)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 8, example: 'StrongPass123!' })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 64)
|
||||||
|
confirmPassword!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
Max,
|
||||||
|
Matches,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
||||||
|
import { MusicRole } from '../../../common/enums/music-role.enum';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@ApiProperty({ example: 'john@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 8 })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 64)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 8 })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 64)
|
||||||
|
confirmPassword!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'John Doe' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(2, 80)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'john_doe' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(3, 30)
|
||||||
|
@Matches(/^[a-zA-Z0-9_.]+$/, { message: 'username can contain letters, numbers, _ and .' })
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'Artist One' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 80)
|
||||||
|
stageName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, maxLength: 160 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 160)
|
||||||
|
bio?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'Riyadh, Saudi Arabia' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 120)
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 24.7136, minimum: -90, maximum: 90 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
latitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 46.6753, minimum: -180, maximum: 180 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
longitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPrivate?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, enum: MusicRole, isArray: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsEnum(MusicRole, { each: true })
|
||||||
|
musicRoles?: MusicRole[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, enum: ExperienceLevel, example: ExperienceLevel.BEGINNER })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ExperienceLevel)
|
||||||
|
experienceLevel?: ExperienceLevel;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [String], example: ['Tarab', 'Pop'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
musicGenres?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [String], example: ['Oud', 'Piano'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
favoriteInstruments?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [String], example: ['Bayati', 'Rast'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
favoriteMaqamat?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
resetToken!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 8, example: 'NewStrongPass123!' })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 64)
|
||||||
|
newPassword!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 8, example: 'NewStrongPass123!' })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 64)
|
||||||
|
confirmPassword!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail } from 'class-validator';
|
||||||
|
|
||||||
|
export class SendEmailVerificationDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class SuperAdminLoginDto {
|
||||||
|
@ApiProperty({ example: 'admin@oudelaa.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SuperAdminStrongPass123!' })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 128)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail, IsString, Length, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class VerifyEmailDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '123456' })
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 6)
|
||||||
|
@Matches(/^\d{6}$/, { message: 'code must be 6 digits' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail, IsString, Length, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class VerifyResetCodeDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '123456' })
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 6)
|
||||||
|
@Matches(/^\d{6}$/, { message: 'code must be 6 digits' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleAuthGuard extends AuthGuard('google') {}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument, Types } from 'mongoose';
|
||||||
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
|
export type EmailVerificationCodeDocument = HydratedDocument<EmailVerificationCode>;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
|
export class EmailVerificationCode {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
|
userId!: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ required: true, select: false })
|
||||||
|
codeHash!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
expiresAt!: Date;
|
||||||
|
|
||||||
|
@Prop({ default: 0, min: 0 })
|
||||||
|
attempts!: number;
|
||||||
|
|
||||||
|
@Prop({ default: false, index: true })
|
||||||
|
used!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailVerificationCodeSchema = SchemaFactory.createForClass(EmailVerificationCode);
|
||||||
|
|
||||||
|
EmailVerificationCodeSchema.index({ userId: 1, used: 1, expiresAt: -1 });
|
||||||
|
EmailVerificationCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument, Types } from 'mongoose';
|
||||||
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
|
export type PasswordResetCodeDocument = HydratedDocument<PasswordResetCode>;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
|
export class PasswordResetCode {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
|
userId!: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ required: true, select: false })
|
||||||
|
codeHash!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
expiresAt!: Date;
|
||||||
|
|
||||||
|
@Prop({ default: 0, min: 0 })
|
||||||
|
attempts!: number;
|
||||||
|
|
||||||
|
@Prop({ default: false, index: true })
|
||||||
|
verified!: boolean;
|
||||||
|
|
||||||
|
@Prop({ default: false, index: true })
|
||||||
|
used!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PasswordResetCodeSchema = SchemaFactory.createForClass(PasswordResetCode);
|
||||||
|
|
||||||
|
PasswordResetCodeSchema.index({ userId: 1, used: 1, expiresAt: -1 });
|
||||||
|
PasswordResetCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument, Types } from 'mongoose';
|
||||||
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
|
export type RefreshTokenDocument = HydratedDocument<RefreshToken>;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
|
export class RefreshToken {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
|
userId!: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ required: true, select: false })
|
||||||
|
tokenHash!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, index: true, unique: true })
|
||||||
|
jti!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
expiresAt!: Date;
|
||||||
|
|
||||||
|
@Prop({ default: false })
|
||||||
|
revoked!: boolean;
|
||||||
|
|
||||||
|
@Prop({ default: false, index: true })
|
||||||
|
compromised!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RefreshTokenSchema = SchemaFactory.createForClass(RefreshToken);
|
||||||
|
|
||||||
|
RefreshTokenSchema.index({ userId: 1, revoked: 1 });
|
||||||
|
RefreshTokenSchema.index({ userId: 1, jti: 1 });
|
||||||
|
RefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument } from 'mongoose';
|
||||||
|
|
||||||
|
export type SuperAdminRefreshTokenDocument = HydratedDocument<SuperAdminRefreshToken>;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
|
export class SuperAdminRefreshToken {
|
||||||
|
@Prop({ required: true, trim: true, lowercase: true, index: true })
|
||||||
|
adminEmail!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, select: false })
|
||||||
|
tokenHash!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
expiresAt!: Date;
|
||||||
|
|
||||||
|
@Prop({ default: false })
|
||||||
|
revoked!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SuperAdminRefreshTokenSchema = SchemaFactory.createForClass(SuperAdminRefreshToken);
|
||||||
|
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, revoked: 1 });
|
||||||
|
SuperAdminRefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||||
|
constructor(configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
clientID:
|
||||||
|
configService.get<string>('google.clientId', { infer: true }) || 'missing-google-client-id',
|
||||||
|
clientSecret:
|
||||||
|
configService.get<string>('google.clientSecret', { infer: true }) ||
|
||||||
|
'missing-google-client-secret',
|
||||||
|
callbackURL:
|
||||||
|
configService.get<string>('google.callbackUrl', { infer: true }) ||
|
||||||
|
'http://localhost:4000/api/v1/auth/google/callback',
|
||||||
|
scope: ['email', 'profile'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(
|
||||||
|
_accessToken: string,
|
||||||
|
_refreshToken: string,
|
||||||
|
profile: Profile,
|
||||||
|
done: VerifyCallback,
|
||||||
|
): void {
|
||||||
|
const email = profile.emails?.[0]?.value?.toLowerCase();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
done(new UnauthorizedException('Google account email is not available'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
done(null, {
|
||||||
|
googleId: profile.id,
|
||||||
|
email,
|
||||||
|
name: profile.displayName ?? 'Google User',
|
||||||
|
avatar: profile.photos?.[0]?.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||||
|
constructor(configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get<string>('jwt.refreshSecret', { infer: true }),
|
||||||
|
passReqToCallback: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(_: Request, payload: JwtPayload): JwtPayload {
|
||||||
|
if (payload.tokenType !== 'refresh') {
|
||||||
|
throw new UnauthorizedException('Invalid token type');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
constructor(configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get<string>('jwt.accessSecret', { infer: true }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(payload: JwtPayload): JwtPayload {
|
||||||
|
if (payload.tokenType !== 'access') {
|
||||||
|
throw new UnauthorizedException('Invalid token type');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SuperAdminJwtStrategy extends PassportStrategy(Strategy, 'superadmin-jwt') {
|
||||||
|
constructor(configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get<string>('superAdmin.accessSecret', { infer: true }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(payload: JwtPayload): JwtPayload {
|
||||||
|
if (payload.tokenType !== 'superadmin_access') {
|
||||||
|
throw new UnauthorizedException('Invalid superadmin token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type TokenPair = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthResult = TokenPair & {
|
||||||
|
user: Record<string, unknown>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
|
import { ChatService } from './chat.service';
|
||||||
|
import { CreateConversationDto } from './dto/create-conversation.dto';
|
||||||
|
import { MessageQueryDto } from './dto/message-query.dto';
|
||||||
|
import { SendMessageDto } from './dto/send-message.dto';
|
||||||
|
|
||||||
|
@ApiTags('Chat')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('chat')
|
||||||
|
export class ChatController {
|
||||||
|
constructor(private readonly chatService: ChatService) {}
|
||||||
|
|
||||||
|
@Post('conversations')
|
||||||
|
@Throttle(40, 60_000)
|
||||||
|
async createConversation(@CurrentUser() user: JwtPayload, @Body() dto: CreateConversationDto) {
|
||||||
|
return this.chatService.createConversation(user.sub, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('conversations')
|
||||||
|
async myConversations(@CurrentUser() user: JwtPayload, @Query() query: MessageQueryDto) {
|
||||||
|
return this.chatService.getMyConversations(user.sub, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('conversations/:conversationId/messages')
|
||||||
|
async messages(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('conversationId') conversationId: string,
|
||||||
|
@Query() query: MessageQueryDto,
|
||||||
|
) {
|
||||||
|
return this.chatService.getMessages(user.sub, conversationId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('messages')
|
||||||
|
@Throttle(120, 60_000)
|
||||||
|
async sendMessage(@CurrentUser() user: JwtPayload, @Body() dto: SendMessageDto) {
|
||||||
|
return this.chatService.sendMessage(user.sub, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('messages/:messageId/seen')
|
||||||
|
@Throttle(200, 60_000)
|
||||||
|
async markSeen(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
|
||||||
|
return this.chatService.markMessageSeen(user.sub, messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('messages/:messageId/unsend')
|
||||||
|
@Throttle(80, 60_000)
|
||||||
|
async unsend(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
|
||||||
|
return this.chatService.unsendMessage(user.sub, messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('blocks/:targetUserId')
|
||||||
|
@Throttle(20, 60_000)
|
||||||
|
async blockUser(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||||
|
return this.chatService.blockUser(user.sub, targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('blocks/:targetUserId/unblock')
|
||||||
|
@Throttle(20, 60_000)
|
||||||
|
async unblockUser(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||||
|
return this.chatService.unblockUser(user.sub, targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('blocks/status/:targetUserId')
|
||||||
|
async blockStatus(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||||
|
return this.chatService.getBlockStatus(user.sub, targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('blocks')
|
||||||
|
async myBlocks(@CurrentUser() user: JwtPayload) {
|
||||||
|
return this.chatService.getMyBlockedUsers(user.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { ChatService } from './chat.service';
|
||||||
|
import { SendMessageDto } from './dto/send-message.dto';
|
||||||
|
|
||||||
|
type SocketWithUser = Socket & { data: { userId?: string } };
|
||||||
|
|
||||||
|
@WebSocketGateway({ cors: { origin: '*' }, namespace: 'chat' })
|
||||||
|
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server!: Server;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly chatService: ChatService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleConnection(client: SocketWithUser) {
|
||||||
|
const token = this.extractToken(client);
|
||||||
|
if (!token) {
|
||||||
|
client.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify<{ sub: string; tokenType: string }>(token, {
|
||||||
|
secret: this.configService.get<string>('jwt.accessSecret', { infer: true }),
|
||||||
|
});
|
||||||
|
if (payload.tokenType !== 'access') {
|
||||||
|
client.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.data.userId = payload.sub;
|
||||||
|
await client.join(this.userRoom(payload.sub));
|
||||||
|
this.server.to(this.userRoom(payload.sub)).emit('presence', { userId: payload.sub, online: true });
|
||||||
|
} catch {
|
||||||
|
client.disconnect(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: SocketWithUser) {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
if (userId) {
|
||||||
|
this.server.to(this.userRoom(userId)).emit('presence', { userId, online: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('join_conversation')
|
||||||
|
async joinConversation(
|
||||||
|
@ConnectedSocket() client: SocketWithUser,
|
||||||
|
@MessageBody() body: { conversationId: string },
|
||||||
|
) {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const conversation = await this.chatService.assertConversationMember(userId, body.conversationId);
|
||||||
|
await client.join(this.conversationRoom(conversation.id));
|
||||||
|
client.emit('joined_conversation', { conversationId: conversation.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('send_message')
|
||||||
|
async sendMessage(
|
||||||
|
@ConnectedSocket() client: SocketWithUser,
|
||||||
|
@MessageBody() dto: SendMessageDto,
|
||||||
|
) {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const message = await this.chatService.sendMessage(userId, dto);
|
||||||
|
this.server.to(this.conversationRoom(message.conversationId.toString())).emit('new_message', message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('typing')
|
||||||
|
async typing(
|
||||||
|
@ConnectedSocket() client: SocketWithUser,
|
||||||
|
@MessageBody() body: { conversationId: string; isTyping: boolean },
|
||||||
|
) {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
await this.chatService.assertConversationMember(userId, body.conversationId);
|
||||||
|
client.to(this.conversationRoom(body.conversationId)).emit('typing', {
|
||||||
|
conversationId: body.conversationId,
|
||||||
|
userId,
|
||||||
|
isTyping: !!body.isTyping,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('mark_seen')
|
||||||
|
async markSeen(
|
||||||
|
@ConnectedSocket() client: SocketWithUser,
|
||||||
|
@MessageBody() body: { messageId: string; conversationId: string },
|
||||||
|
) {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
await this.chatService.markMessageSeen(userId, body.messageId);
|
||||||
|
this.server.to(this.conversationRoom(body.conversationId)).emit('message_seen', {
|
||||||
|
messageId: body.messageId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractToken(client: Socket): string | null {
|
||||||
|
const authToken = client.handshake.auth?.token;
|
||||||
|
if (typeof authToken === 'string' && authToken.trim()) {
|
||||||
|
return authToken.replace(/^Bearer\s+/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerAuth = client.handshake.headers.authorization;
|
||||||
|
if (typeof headerAuth === 'string' && headerAuth.trim()) {
|
||||||
|
return headerAuth.replace(/^Bearer\s+/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private userRoom(userId: string): string {
|
||||||
|
return `user:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private conversationRoom(conversationId: string): string {
|
||||||
|
return `conversation:${conversationId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { ChatController } from './chat.controller';
|
||||||
|
import { ChatGateway } from './chat.gateway';
|
||||||
|
import { ChatService } from './chat.service';
|
||||||
|
import { ChatRepository } from './chat.repository';
|
||||||
|
import { ChatBlock, ChatBlockSchema } from './schemas/chat-block.schema';
|
||||||
|
import { Conversation, ConversationSchema } from './schemas/conversation.schema';
|
||||||
|
import { Message, MessageSchema } from './schemas/message.schema';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
|
JwtModule.register({}),
|
||||||
|
UsersModule,
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Conversation.name, schema: ConversationSchema },
|
||||||
|
{ name: Message.name, schema: MessageSchema },
|
||||||
|
{ name: ChatBlock.name, schema: ChatBlockSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [ChatController],
|
||||||
|
providers: [ChatService, ChatRepository, ChatGateway],
|
||||||
|
exports: [ChatService],
|
||||||
|
})
|
||||||
|
export class ChatModule {}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { FilterQuery, Model, Types } from 'mongoose';
|
||||||
|
import { ChatBlock, ChatBlockDocument } from './schemas/chat-block.schema';
|
||||||
|
import { Conversation, ConversationDocument } from './schemas/conversation.schema';
|
||||||
|
import { Message, MessageDocument } from './schemas/message.schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Conversation.name) private readonly conversationModel: Model<ConversationDocument>,
|
||||||
|
@InjectModel(Message.name) private readonly messageModel: Model<MessageDocument>,
|
||||||
|
@InjectModel(ChatBlock.name) private readonly chatBlockModel: Model<ChatBlockDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findConversationById(id: string): Promise<ConversationDocument | null> {
|
||||||
|
return this.conversationModel.findById(id).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDirectConversation(userAId: string, userBId: string): Promise<ConversationDocument | null> {
|
||||||
|
return this.conversationModel
|
||||||
|
.findOne({
|
||||||
|
isGroup: false,
|
||||||
|
participantIds: {
|
||||||
|
$all: [new Types.ObjectId(userAId), new Types.ObjectId(userBId)],
|
||||||
|
$size: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConversation(payload: {
|
||||||
|
participantIds: string[];
|
||||||
|
isGroup: boolean;
|
||||||
|
title?: string;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<ConversationDocument> {
|
||||||
|
const participantIds = payload.participantIds.map((id) => new Types.ObjectId(id));
|
||||||
|
const unreadCountByUser: Record<string, number> = {};
|
||||||
|
payload.participantIds.forEach((id) => {
|
||||||
|
unreadCountByUser[id] = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.conversationModel.create({
|
||||||
|
participantIds,
|
||||||
|
isGroup: payload.isGroup,
|
||||||
|
title: payload.title ?? '',
|
||||||
|
createdBy: new Types.ObjectId(payload.createdBy),
|
||||||
|
unreadCountByUser,
|
||||||
|
lastMessageText: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findConversationsForUser(userId: string, skip: number, limit: number): Promise<ConversationDocument[]> {
|
||||||
|
return this.conversationModel
|
||||||
|
.find({ participantIds: new Types.ObjectId(userId) })
|
||||||
|
.populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' })
|
||||||
|
.sort({ lastMessageAt: -1, updatedAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countConversationsForUser(userId: string): Promise<number> {
|
||||||
|
return this.conversationModel.countDocuments({ participantIds: new Types.ObjectId(userId) }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMessage(payload: {
|
||||||
|
conversationId: string;
|
||||||
|
senderId: string;
|
||||||
|
content?: string;
|
||||||
|
messageType: 'text' | 'image' | 'video' | 'audio';
|
||||||
|
mediaUrl?: string;
|
||||||
|
}): Promise<MessageDocument> {
|
||||||
|
return this.messageModel.create({
|
||||||
|
conversationId: new Types.ObjectId(payload.conversationId),
|
||||||
|
senderId: new Types.ObjectId(payload.senderId),
|
||||||
|
content: payload.content ?? '',
|
||||||
|
messageType: payload.messageType,
|
||||||
|
mediaUrl: payload.mediaUrl ?? '',
|
||||||
|
seenBy: [new Types.ObjectId(payload.senderId)],
|
||||||
|
isUnsent: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMessages(conversationId: string, skip: number, limit: number): Promise<MessageDocument[]> {
|
||||||
|
return this.messageModel
|
||||||
|
.find({ conversationId: new Types.ObjectId(conversationId) })
|
||||||
|
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countMessages(conversationId: string): Promise<number> {
|
||||||
|
return this.messageModel.countDocuments({ conversationId: new Types.ObjectId(conversationId) }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMessageById(messageId: string): Promise<MessageDocument | null> {
|
||||||
|
return this.messageModel.findById(messageId).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markMessageSeen(messageId: string, userId: string): Promise<void> {
|
||||||
|
await this.messageModel
|
||||||
|
.findByIdAndUpdate(messageId, { $addToSet: { seenBy: new Types.ObjectId(userId) } }, { new: false })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConversationAfterNewMessage(
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
senderId: string,
|
||||||
|
messageText: string,
|
||||||
|
): Promise<ConversationDocument | null> {
|
||||||
|
const conversation = await this.conversationModel.findById(conversationId).exec();
|
||||||
|
if (!conversation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadMap = new Map<string, number>(
|
||||||
|
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const participantId of conversation.participantIds) {
|
||||||
|
const id = participantId.toString();
|
||||||
|
if (id === senderId) {
|
||||||
|
unreadMap.set(id, 0);
|
||||||
|
} else {
|
||||||
|
unreadMap.set(id, (unreadMap.get(id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.lastMessageId = new Types.ObjectId(messageId);
|
||||||
|
conversation.lastMessageText = messageText.slice(0, 4000);
|
||||||
|
conversation.lastMessageAt = new Date();
|
||||||
|
conversation.unreadCountByUser = unreadMap as unknown as Map<string, number>;
|
||||||
|
await conversation.save();
|
||||||
|
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearConversationUnreadForUser(conversationId: string, userId: string): Promise<void> {
|
||||||
|
const conversation = await this.conversationModel.findById(conversationId).exec();
|
||||||
|
if (!conversation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadMap = new Map<string, number>(
|
||||||
|
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
|
||||||
|
);
|
||||||
|
unreadMap.set(userId, 0);
|
||||||
|
conversation.unreadCountByUser = unreadMap as unknown as Map<string, number>;
|
||||||
|
await conversation.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async unsendMessage(messageId: string, senderId: string): Promise<MessageDocument | null> {
|
||||||
|
return this.messageModel
|
||||||
|
.findOneAndUpdate(
|
||||||
|
{ _id: new Types.ObjectId(messageId), senderId: new Types.ObjectId(senderId) },
|
||||||
|
{
|
||||||
|
isUnsent: true,
|
||||||
|
content: '',
|
||||||
|
mediaUrl: '',
|
||||||
|
messageType: 'text',
|
||||||
|
},
|
||||||
|
{ new: true },
|
||||||
|
)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findManyMessages(filter: FilterQuery<MessageDocument>): Promise<MessageDocument[]> {
|
||||||
|
return this.messageModel.find(filter).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBlock(blockerId: string, blockedId: string): Promise<void> {
|
||||||
|
await this.chatBlockModel
|
||||||
|
.updateOne(
|
||||||
|
{ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) },
|
||||||
|
{
|
||||||
|
$setOnInsert: {
|
||||||
|
blockerId: new Types.ObjectId(blockerId),
|
||||||
|
blockedId: new Types.ObjectId(blockedId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBlock(blockerId: string, blockedId: string): Promise<void> {
|
||||||
|
await this.chatBlockModel
|
||||||
|
.deleteOne({ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBlock(blockerId: string, blockedId: string): Promise<ChatBlockDocument | null> {
|
||||||
|
return this.chatBlockModel
|
||||||
|
.findOne({ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAnyBlockBetween(userAId: string, userBId: string): Promise<ChatBlockDocument | null> {
|
||||||
|
return this.chatBlockModel
|
||||||
|
.findOne({
|
||||||
|
$or: [
|
||||||
|
{ blockerId: new Types.ObjectId(userAId), blockedId: new Types.ObjectId(userBId) },
|
||||||
|
{ blockerId: new Types.ObjectId(userBId), blockedId: new Types.ObjectId(userAId) },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBlocksByBlocker(blockerId: string): Promise<ChatBlockDocument[]> {
|
||||||
|
return this.chatBlockModel
|
||||||
|
.find({ blockerId: new Types.ObjectId(blockerId) })
|
||||||
|
.populate({ path: 'blockedId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Types } from 'mongoose';
|
||||||
|
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||||
|
import { UsersRepository } from '../users/users.repository';
|
||||||
|
import { CreateConversationDto } from './dto/create-conversation.dto';
|
||||||
|
import { MessageQueryDto } from './dto/message-query.dto';
|
||||||
|
import { SendMessageDto } from './dto/send-message.dto';
|
||||||
|
import { ChatRepository } from './chat.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatService {
|
||||||
|
constructor(
|
||||||
|
private readonly chatRepository: ChatRepository,
|
||||||
|
private readonly usersRepository: UsersRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createConversation(currentUserId: string, dto: CreateConversationDto) {
|
||||||
|
const uniqueParticipantIds = Array.from(new Set([currentUserId, ...dto.participantIds]));
|
||||||
|
if (uniqueParticipantIds.length < 2) {
|
||||||
|
throw new BadRequestException('Conversation must include at least 2 participants');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const participantId of uniqueParticipantIds) {
|
||||||
|
if (!Types.ObjectId.isValid(participantId)) {
|
||||||
|
throw new BadRequestException('Invalid participant id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await Promise.all(uniqueParticipantIds.map((id) => this.usersRepository.findById(id)));
|
||||||
|
if (users.some((u) => !u || u.isDisabled)) {
|
||||||
|
throw new BadRequestException('One or more participants are invalid or disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGroup = dto.isGroup ?? uniqueParticipantIds.length > 2;
|
||||||
|
if (!isGroup && uniqueParticipantIds.length !== 2) {
|
||||||
|
throw new BadRequestException('Direct conversation must contain exactly 2 participants');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGroup) {
|
||||||
|
const otherId = uniqueParticipantIds.find((id) => id !== currentUserId) as string;
|
||||||
|
const block = await this.chatRepository.findAnyBlockBetween(currentUserId, otherId);
|
||||||
|
if (block) {
|
||||||
|
throw new ForbiddenException('You cannot start chat with this user');
|
||||||
|
}
|
||||||
|
const existing = await this.chatRepository.findDirectConversation(currentUserId, otherId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.chatRepository.createConversation({
|
||||||
|
participantIds: uniqueParticipantIds,
|
||||||
|
isGroup,
|
||||||
|
title: dto.title,
|
||||||
|
createdBy: currentUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyConversations(currentUserId: string, query: MessageQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||||
|
const skip = cursorOffset ?? (page - 1) * limit;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.chatRepository.findConversationsForUser(currentUserId, skip, limit),
|
||||||
|
this.chatRepository.countConversationsForUser(currentUserId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mappedItems = items.map((conversation) => {
|
||||||
|
const unreadMap = (conversation.unreadCountByUser as unknown as Record<string, number>) ?? {};
|
||||||
|
return {
|
||||||
|
...conversation.toObject(),
|
||||||
|
unreadCount: unreadMap[currentUserId] ?? 0,
|
||||||
|
lastMessageAt: conversation.lastMessageAt ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const nextOffset = skip + mappedItems.length;
|
||||||
|
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: mappedItems,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit) || 1,
|
||||||
|
nextCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
|
||||||
|
const conversation = await this.assertConversationMember(currentUserId, conversationId);
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||||
|
const skip = cursorOffset ?? (page - 1) * limit;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.chatRepository.findMessages(conversation.id, skip, limit),
|
||||||
|
this.chatRepository.countMessages(conversation.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await this.chatRepository.clearConversationUnreadForUser(conversation.id, currentUserId);
|
||||||
|
const nextOffset = skip + items.length;
|
||||||
|
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit) || 1,
|
||||||
|
nextCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(currentUserId: string, dto: SendMessageDto) {
|
||||||
|
const conversation = await this.assertConversationMember(currentUserId, dto.conversationId);
|
||||||
|
await this.assertNoChatBlockInConversation(currentUserId, conversation.participantIds.map((id) => id.toString()));
|
||||||
|
const messageType = dto.messageType ?? 'text';
|
||||||
|
const content = dto.content?.trim() ?? '';
|
||||||
|
const mediaUrl = dto.mediaUrl?.trim() ?? '';
|
||||||
|
|
||||||
|
if (messageType === 'text' && !content) {
|
||||||
|
throw new BadRequestException('Text message content is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType !== 'text' && !mediaUrl) {
|
||||||
|
throw new BadRequestException('mediaUrl is required for non-text messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await this.chatRepository.createMessage({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
senderId: currentUserId,
|
||||||
|
content,
|
||||||
|
messageType,
|
||||||
|
mediaUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = messageType === 'text' ? content : `${messageType} message`;
|
||||||
|
await this.chatRepository.updateConversationAfterNewMessage(
|
||||||
|
conversation.id,
|
||||||
|
message.id,
|
||||||
|
currentUserId,
|
||||||
|
preview,
|
||||||
|
);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markMessageSeen(currentUserId: string, messageId: string) {
|
||||||
|
const message = await this.chatRepository.findMessageById(messageId);
|
||||||
|
if (!message) {
|
||||||
|
throw new NotFoundException('Message not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assertConversationMember(currentUserId, message.conversationId.toString());
|
||||||
|
await this.chatRepository.markMessageSeen(message.id, currentUserId);
|
||||||
|
await this.chatRepository.clearConversationUnreadForUser(message.conversationId.toString(), currentUserId);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async unsendMessage(currentUserId: string, messageId: string) {
|
||||||
|
const message = await this.chatRepository.findMessageById(messageId);
|
||||||
|
if (!message) {
|
||||||
|
throw new NotFoundException('Message not found');
|
||||||
|
}
|
||||||
|
if (message.senderId.toString() !== currentUserId) {
|
||||||
|
throw new ForbiddenException('You can only unsend your own messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.chatRepository.unsendMessage(messageId, currentUserId);
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException('Message not found');
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async blockUser(currentUserId: string, targetUserId: string) {
|
||||||
|
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||||
|
throw new BadRequestException('Invalid target user id');
|
||||||
|
}
|
||||||
|
if (currentUserId === targetUserId) {
|
||||||
|
throw new BadRequestException('You cannot block yourself');
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await this.usersRepository.findById(targetUserId);
|
||||||
|
if (!target) {
|
||||||
|
throw new NotFoundException('Target user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.chatRepository.createBlock(currentUserId, targetUserId);
|
||||||
|
return { blocked: true, targetUserId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async unblockUser(currentUserId: string, targetUserId: string) {
|
||||||
|
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||||
|
throw new BadRequestException('Invalid target user id');
|
||||||
|
}
|
||||||
|
await this.chatRepository.removeBlock(currentUserId, targetUserId);
|
||||||
|
return { blocked: false, targetUserId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBlockStatus(currentUserId: string, targetUserId: string) {
|
||||||
|
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||||
|
throw new BadRequestException('Invalid target user id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iBlocked = !!(await this.chatRepository.findBlock(currentUserId, targetUserId));
|
||||||
|
const blockedMe = !!(await this.chatRepository.findBlock(targetUserId, currentUserId));
|
||||||
|
|
||||||
|
return { targetUserId, iBlocked, blockedMe };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyBlockedUsers(currentUserId: string) {
|
||||||
|
const items = await this.chatRepository.findBlocksByBlocker(currentUserId);
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertConversationMember(userId: string, conversationId: string) {
|
||||||
|
if (!Types.ObjectId.isValid(conversationId)) {
|
||||||
|
throw new BadRequestException('Invalid conversation id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = await this.chatRepository.findConversationById(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
throw new NotFoundException('Conversation not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMember = conversation.participantIds.some((id) => id.toString() === userId);
|
||||||
|
if (!isMember) {
|
||||||
|
throw new ForbiddenException('You are not a member of this conversation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNoChatBlockInConversation(currentUserId: string, participantIds: string[]) {
|
||||||
|
for (const participantId of participantIds) {
|
||||||
|
if (participantId === currentUserId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const block = await this.chatRepository.findAnyBlockBetween(currentUserId, participantId);
|
||||||
|
if (block) {
|
||||||
|
throw new ForbiddenException('Cannot send message because one of participants is blocked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateConversationDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
participantIds!: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isGroup?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 120 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 120)
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
|
||||||
|
export class MessageQueryDto extends PaginationQueryDto {}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class SendMessageDto {
|
||||||
|
@IsString()
|
||||||
|
conversationId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 4000 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 4000)
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['text', 'image', 'video', 'audio'], default: 'text' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['text', 'image', 'video', 'audio'])
|
||||||
|
messageType?: 'text' | 'image' | 'video' | 'audio';
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({ require_tld: false })
|
||||||
|
mediaUrl?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument, Types } from 'mongoose';
|
||||||
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
|
export type ChatBlockDocument = HydratedDocument<ChatBlock>;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
|
export class ChatBlock {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
|
blockerId!: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
|
blockedId!: Types.ObjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatBlockSchema = SchemaFactory.createForClass(ChatBlock);
|
||||||
|
ChatBlockSchema.index({ blockerId: 1, blockedId: 1 }, { unique: true });
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument, Types } from 'mongoose';
|
||||||
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
|
export type ConversationDocument = HydratedDocument<Conversation>;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
|
export class Conversation {
|
||||||
|
@Prop({ type: [Types.ObjectId], ref: User.name, required: true, index: true })
|
||||||
|
participantIds!: Types.ObjectId[];
|
||||||
|
|
||||||
|
@Prop({ default: false, index: true })
|
||||||
|
isGroup!: boolean;
|
||||||
|
|
||||||
|
@Prop({ default: '', maxlength: 120, trim: true })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, ref: User.name, required: false, index: true })
|
||||||
|
createdBy?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, required: false, index: true })
|
||||||
|
lastMessageId?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ default: '', maxlength: 4000 })
|
||||||
|
lastMessageText!: string;
|
||||||
|
|
||||||
|
@Prop({ type: Date, required: false, index: true })
|
||||||
|
lastMessageAt?: Date;
|
||||||
|
|
||||||
|
@Prop({ type: Map, of: Number, default: {} })
|
||||||
|
unreadCountByUser!: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConversationSchema = SchemaFactory.createForClass(Conversation);
|
||||||
|
ConversationSchema.index({ participantIds: 1, updatedAt: -1 });
|
||||||
|
ConversationSchema.index({ lastMessageAt: -1, updatedAt: -1 });
|
||||||
|
ConversationSchema.index({ participantIds: 1, isGroup: 1, lastMessageAt: -1 });
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument, Types } from 'mongoose';
|
||||||
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
|
export type MessageDocument = HydratedDocument<Message>;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
|
export class Message {
|
||||||
|
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||||
|
conversationId!: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
|
senderId!: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ required: false, default: '', maxlength: 4000 })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@Prop({ enum: ['text', 'image', 'video', 'audio'], default: 'text', index: true })
|
||||||
|
messageType!: 'text' | 'image' | 'video' | 'audio';
|
||||||
|
|
||||||
|
@Prop({ required: false, default: '' })
|
||||||
|
mediaUrl!: string;
|
||||||
|
|
||||||
|
@Prop({ type: [Types.ObjectId], ref: User.name, default: [] })
|
||||||
|
seenBy!: Types.ObjectId[];
|
||||||
|
|
||||||
|
@Prop({ default: false, index: true })
|
||||||
|
isUnsent!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageSchema = SchemaFactory.createForClass(Message);
|
||||||
|
MessageSchema.index({ conversationId: 1, createdAt: -1 });
|
||||||
|
MessageSchema.index({ conversationId: 1, isUnsent: 1, createdAt: -1 });
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||||
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
|
import { CommentQueryDto } from './dto/comment-query.dto';
|
||||||
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
|
import { CommentsService } from './comments.service';
|
||||||
|
|
||||||
|
@ApiTags('Comments')
|
||||||
|
@Controller('comments')
|
||||||
|
export class CommentsController {
|
||||||
|
constructor(private readonly commentsService: CommentsService) {}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreateCommentDto) {
|
||||||
|
return this.commentsService.create(user.sub, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('post/:postId')
|
||||||
|
async findByPost(@Param('postId') postId: string, @Query() query: CommentQueryDto) {
|
||||||
|
return this.commentsService.findByPost(postId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get(':commentId/replies')
|
||||||
|
async findReplies(@Param('commentId') commentId: string, @Query() query: CommentQueryDto) {
|
||||||
|
return this.commentsService.findReplies(commentId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete(':commentId')
|
||||||
|
async remove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
||||||
|
return this.commentsService.remove(user.sub, commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(SuperAdminJwtAuthGuard)
|
||||||
|
@Delete('admin/:commentId')
|
||||||
|
async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
||||||
|
return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { PostsModule } from '../posts/posts.module';
|
||||||
|
import { Comment, CommentSchema } from './schemas/comment.schema';
|
||||||
|
import { CommentsController } from './comments.controller';
|
||||||
|
import { CommentsService } from './comments.service';
|
||||||
|
import { CommentsRepository } from './comments.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AuditModule,
|
||||||
|
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
|
||||||
|
PostsModule,
|
||||||
|
],
|
||||||
|
controllers: [CommentsController],
|
||||||
|
providers: [CommentsService, CommentsRepository],
|
||||||
|
exports: [CommentsService, CommentsRepository],
|
||||||
|
})
|
||||||
|
export class CommentsModule {}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
|
||||||
|
import { Comment, CommentDocument } from './schemas/comment.schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentsRepository {
|
||||||
|
constructor(@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>) {}
|
||||||
|
|
||||||
|
private withActiveFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
|
||||||
|
return {
|
||||||
|
...filter,
|
||||||
|
isDeleted: { $ne: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
payload: { postId: string; authorId: string; content: string; parentCommentId?: string },
|
||||||
|
session?: ClientSession,
|
||||||
|
) {
|
||||||
|
return this.commentModel.create({
|
||||||
|
postId: new Types.ObjectId(payload.postId),
|
||||||
|
authorId: new Types.ObjectId(payload.authorId),
|
||||||
|
content: payload.content,
|
||||||
|
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
|
||||||
|
}, { session });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(commentId: string): Promise<CommentDocument | null> {
|
||||||
|
if (!Types.ObjectId.isValid(commentId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.commentModel
|
||||||
|
.findOne({ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteById(commentId: string, deletedBy?: string, session?: ClientSession): Promise<boolean> {
|
||||||
|
if (!Types.ObjectId.isValid(commentId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedByObjectId =
|
||||||
|
deletedBy && Types.ObjectId.isValid(deletedBy) ? new Types.ObjectId(deletedBy) : null;
|
||||||
|
|
||||||
|
const updated = await this.commentModel
|
||||||
|
.findOneAndUpdate(
|
||||||
|
{ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } },
|
||||||
|
{ isDeleted: true, deletedAt: new Date(), deletedBy: deletedByObjectId },
|
||||||
|
{ new: false, session },
|
||||||
|
)
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
return !!updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMany(filter: FilterQuery<CommentDocument>, skip: number, limit: number) {
|
||||||
|
return this.commentModel
|
||||||
|
.find(this.withActiveFilter(filter))
|
||||||
|
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(filter: FilterQuery<CommentDocument>): Promise<number> {
|
||||||
|
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countByPost(postId: string): Promise<number> {
|
||||||
|
return this.commentModel
|
||||||
|
.countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { PostsRepository } from '../posts/posts.repository';
|
||||||
|
import { CommentQueryDto } from './dto/comment-query.dto';
|
||||||
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
|
import { CommentsRepository } from './comments.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentsService {
|
||||||
|
constructor(
|
||||||
|
private readonly commentsRepository: CommentsRepository,
|
||||||
|
private readonly postsRepository: PostsRepository,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(userId: string, dto: CreateCommentDto) {
|
||||||
|
const post = await this.postsRepository.findById(dto.postId);
|
||||||
|
if (!post) {
|
||||||
|
throw new NotFoundException('Post not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.parentCommentId) {
|
||||||
|
const parent = await this.commentsRepository.findById(dto.parentCommentId);
|
||||||
|
if (!parent || parent.postId.toString() !== dto.postId) {
|
||||||
|
throw new NotFoundException('Parent comment not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await this.commentsRepository.create({
|
||||||
|
postId: dto.postId,
|
||||||
|
authorId: userId,
|
||||||
|
content: dto.content,
|
||||||
|
parentCommentId: dto.parentCommentId,
|
||||||
|
});
|
||||||
|
await this.syncCommentsCount(dto.postId);
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(userId: string, commentId: string) {
|
||||||
|
const comment = await this.commentsRepository.findById(commentId);
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.authorId.toString() !== userId) {
|
||||||
|
throw new ForbiddenException('You can only delete your own comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.commentsRepository.deleteById(commentId, userId);
|
||||||
|
await this.syncCommentsCount(comment.postId.toString());
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBySuperAdmin(superAdminIdentifier: string, commentId: string) {
|
||||||
|
const comment = await this.commentsRepository.findById(commentId);
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.commentsRepository.deleteById(commentId, superAdminIdentifier);
|
||||||
|
await this.syncCommentsCount(comment.postId.toString());
|
||||||
|
await this.auditService.logSuperAdminAction(
|
||||||
|
superAdminIdentifier,
|
||||||
|
'comment_delete',
|
||||||
|
'comment',
|
||||||
|
commentId,
|
||||||
|
{ postId: comment.postId.toString() },
|
||||||
|
);
|
||||||
|
return { success: true, message: 'Comment deleted by superadmin' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByPost(postId: string, query: CommentQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.commentsRepository.findMany({ postId, parentCommentId: { $exists: false } }, skip, limit),
|
||||||
|
this.commentsRepository.count({ postId, parentCommentId: { $exists: false } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit) || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findReplies(parentCommentId: string, query: CommentQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.commentsRepository.findMany({ parentCommentId }, skip, limit),
|
||||||
|
this.commentsRepository.count({ parentCommentId }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit) || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncCommentsCount(postId: string): Promise<void> {
|
||||||
|
const totalComments = await this.commentsRepository.countByPost(postId);
|
||||||
|
await this.postsRepository.setCommentsCount(postId, totalComments);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
|
||||||
|
export class CommentQueryDto extends PaginationQueryDto {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsMongoId, IsOptional, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCommentDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsMongoId()
|
||||||
|
postId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 1000)
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsMongoId()
|
||||||
|
parentCommentId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsOptional, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateCommentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 1000)
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument, Types } from 'mongoose';
|
||||||
|
import { Post } from '../../posts/schemas/post.schema';
|
||||||
|
import { User } from '../../users/schemas/user.schema';
|
||||||
|
|
||||||
|
export type CommentDocument = HydratedDocument<Comment>;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
|
export class Comment {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: Post.name, required: true, index: true })
|
||||||
|
postId!: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
|
authorId!: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, required: false, index: true })
|
||||||
|
parentCommentId?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ required: true, maxlength: 1000 })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@Prop({ default: false, index: true })
|
||||||
|
isDeleted!: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Date, default: null })
|
||||||
|
deletedAt?: Date | null;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, ref: User.name, default: null })
|
||||||
|
deletedBy?: Types.ObjectId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommentSchema = SchemaFactory.createForClass(Comment);
|
||||||
|
CommentSchema.index({ postId: 1, createdAt: -1 });
|
||||||
|
CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 });
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EmailService } from './email.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [EmailService],
|
||||||
|
exports: [EmailService],
|
||||||
|
})
|
||||||
|
export class EmailModule {}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as nodemailer from 'nodemailer';
|
||||||
|
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailService {
|
||||||
|
private readonly logger = new Logger(EmailService.name);
|
||||||
|
private transporter: nodemailer.Transporter<SMTPTransport.SentMessageInfo> | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
async sendVerificationCode(email: string, code: string, expiresMinutes: number): Promise<void> {
|
||||||
|
const subject = 'تأكيد البريد الإلكتروني - Oudelaa';
|
||||||
|
const text = [
|
||||||
|
'مرحباً،',
|
||||||
|
`رمز تأكيد الحساب الخاص بك هو: ${code}`,
|
||||||
|
`صلاحية الرمز: ${expiresMinutes} دقيقة.`,
|
||||||
|
'إذا لم تطلب هذا الرمز، تجاهل هذه الرسالة.',
|
||||||
|
].join('\n');
|
||||||
|
const html = this.buildCodeEmailHtml({
|
||||||
|
title: 'تأكيد البريد الإلكتروني',
|
||||||
|
intro: 'استخدم الرمز التالي لإكمال تفعيل حسابك في Oudelaa:',
|
||||||
|
code,
|
||||||
|
expiresMinutes,
|
||||||
|
footerNote: 'إذا لم تطلب هذا الرمز، يمكنك تجاهل الرسالة بأمان.',
|
||||||
|
});
|
||||||
|
await this.send(email, subject, text, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPasswordResetCode(email: string, code: string, expiresMinutes: number): Promise<void> {
|
||||||
|
const subject = 'إعادة تعيين كلمة المرور - Oudelaa';
|
||||||
|
const text = [
|
||||||
|
'مرحباً،',
|
||||||
|
`رمز إعادة تعيين كلمة المرور هو: ${code}`,
|
||||||
|
`صلاحية الرمز: ${expiresMinutes} دقيقة.`,
|
||||||
|
'إذا لم تطلب إعادة تعيين كلمة المرور، ننصحك بتغيير كلمة المرور مباشرة.',
|
||||||
|
].join('\n');
|
||||||
|
const html = this.buildCodeEmailHtml({
|
||||||
|
title: 'إعادة تعيين كلمة المرور',
|
||||||
|
intro: 'استخدم الرمز التالي لإعادة تعيين كلمة المرور:',
|
||||||
|
code,
|
||||||
|
expiresMinutes,
|
||||||
|
footerNote: 'إذا لم تطلب إعادة التعيين، تجاهل هذه الرسالة وقم بمراجعة أمان حسابك.',
|
||||||
|
});
|
||||||
|
await this.send(email, subject, text, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCodeEmailHtml(params: {
|
||||||
|
title: string;
|
||||||
|
intro: string;
|
||||||
|
code: string;
|
||||||
|
expiresMinutes: number;
|
||||||
|
footerNote: string;
|
||||||
|
}): string {
|
||||||
|
return `
|
||||||
|
<div style="font-family: Arial, sans-serif; background:#f6f8fb; padding:24px; direction:rtl; text-align:right;">
|
||||||
|
<div style="max-width:520px; margin:0 auto; background:#ffffff; border-radius:12px; padding:24px; border:1px solid #e8edf3;">
|
||||||
|
<h2 style="margin:0 0 12px; color:#111827;">${params.title}</h2>
|
||||||
|
<p style="margin:0 0 16px; color:#374151; line-height:1.7;">${params.intro}</p>
|
||||||
|
<div style="font-size:32px; letter-spacing:6px; font-weight:700; color:#0f172a; background:#f1f5f9; border-radius:10px; text-align:center; padding:14px 8px; margin:0 0 14px;">
|
||||||
|
${params.code}
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 10px; color:#475569;">صلاحية الرمز: <strong>${params.expiresMinutes} دقيقة</strong></p>
|
||||||
|
<p style="margin:0; color:#64748b; font-size:13px;">${params.footerNote}</p>
|
||||||
|
<hr style="border:none; border-top:1px solid #e5e7eb; margin:18px 0;" />
|
||||||
|
<p style="margin:0; color:#94a3b8; font-size:12px;">Oudelaa Team</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send(to: string, subject: string, text: string, html: string): Promise<void> {
|
||||||
|
const enabled = this.configService.get<boolean>('email.enabled', { infer: true });
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromName = this.configService.get<string>('email.fromName', { infer: true }) ?? 'Oudelaa';
|
||||||
|
const fromEmail = this.configService.get<string>('email.fromEmail', { infer: true }) ?? '';
|
||||||
|
if (!fromEmail) {
|
||||||
|
throw new ServiceUnavailableException('Email sender is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = this.getTransporter();
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `${fromName} <${fromEmail}>`,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send email to ${to}`, error as Error);
|
||||||
|
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||||
|
if (nodeEnv === 'development') {
|
||||||
|
const err = error as Error & { code?: string; message?: string };
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Failed to send verification email (${err.code ?? 'SMTP_ERROR'}: ${err.message ?? 'unknown'})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException('Failed to send verification email');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTransporter(): nodemailer.Transporter<SMTPTransport.SentMessageInfo> {
|
||||||
|
if (this.transporter) {
|
||||||
|
return this.transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = this.configService.get<string>('email.smtpHost', { infer: true }) ?? '';
|
||||||
|
const port = this.configService.get<number>('email.smtpPort', { infer: true }) ?? 587;
|
||||||
|
const secure = this.configService.get<boolean>('email.smtpSecure', { infer: true }) ?? false;
|
||||||
|
const user = this.configService.get<string>('email.smtpUser', { infer: true }) ?? '';
|
||||||
|
const pass = this.configService.get<string>('email.smtpPass', { infer: true }) ?? '';
|
||||||
|
|
||||||
|
if (!host || !user || !pass) {
|
||||||
|
throw new ServiceUnavailableException('SMTP settings are not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
auth: {
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.transporter;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
import { IsBoolean, IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { PostType } from '../../../common/enums/post-type.enum';
|
||||||
|
|
||||||
|
export class FeedQueryDto extends PaginationQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(PostType)
|
||||||
|
preferredPostType?: PostType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
@IsBoolean()
|
||||||
|
followingOnly?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(500)
|
||||||
|
radiusKm?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
|
import { FeedQueryDto } from './dto/feed-query.dto';
|
||||||
|
import { FeedService } from './feed.service';
|
||||||
|
|
||||||
|
@ApiTags('Feed')
|
||||||
|
@Controller('feed')
|
||||||
|
export class FeedController {
|
||||||
|
constructor(private readonly feedService: FeedService) {}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('me')
|
||||||
|
async myFeed(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
|
||||||
|
return this.feedService.getMyFeed(user.sub, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('trending')
|
||||||
|
async trending(@Query() query: FeedQueryDto) {
|
||||||
|
return this.feedService.getTrending(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { Follow, FollowSchema } from '../follows/schemas/follow.schema';
|
||||||
|
import { Post, PostSchema } from '../posts/schemas/post.schema';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { FeedController } from './feed.controller';
|
||||||
|
import { FeedService } from './feed.service';
|
||||||
|
import { FeedRepository } from './feed.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
UsersModule,
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Post.name, schema: PostSchema },
|
||||||
|
{ name: Follow.name, schema: FollowSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [FeedController],
|
||||||
|
providers: [FeedService, FeedRepository],
|
||||||
|
exports: [FeedService],
|
||||||
|
})
|
||||||
|
export class FeedModule {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { FilterQuery, Model, Types } from 'mongoose';
|
||||||
|
import { Follow, FollowDocument } from '../follows/schemas/follow.schema';
|
||||||
|
import { Post, PostDocument } from '../posts/schemas/post.schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FeedRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Post.name) private readonly postModel: Model<PostDocument>,
|
||||||
|
@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findFollowingIds(userId: string): Promise<string[]> {
|
||||||
|
const rows = await this.followModel
|
||||||
|
.find({ followerId: new Types.ObjectId(userId) })
|
||||||
|
.select({ followingId: 1 })
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
return rows.map((row) => row.followingId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCandidatePosts(
|
||||||
|
filter: FilterQuery<PostDocument>,
|
||||||
|
limit: number,
|
||||||
|
): Promise<PostDocument[]> {
|
||||||
|
const activeFilter: FilterQuery<PostDocument> = {
|
||||||
|
...filter,
|
||||||
|
isDeleted: { $ne: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.postModel
|
||||||
|
.find(activeFilter)
|
||||||
|
.populate({
|
||||||
|
path: 'authorId',
|
||||||
|
select:
|
||||||
|
'name username stageName avatar isVerified isDisabled location latitude longitude musicGenres musicRoles favoriteInstruments favoriteMaqamat',
|
||||||
|
})
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findTrendingPublicPosts(skip: number, limit: number): Promise<PostDocument[]> {
|
||||||
|
return this.postModel
|
||||||
|
.find({ visibility: 'public', isDeleted: { $ne: true } })
|
||||||
|
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||||
|
.sort({ likesCount: -1, commentsCount: -1, savesCount: -1, createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(filter: FilterQuery<PostDocument>): Promise<number> {
|
||||||
|
return this.postModel.countDocuments({ ...filter, isDeleted: { $ne: true } }).exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Types } from 'mongoose';
|
||||||
|
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||||
|
import { PostType } from '../../common/enums/post-type.enum';
|
||||||
|
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||||
|
import { UsersRepository } from '../users/users.repository';
|
||||||
|
import { UserDocument } from '../users/schemas/user.schema';
|
||||||
|
import { FeedQueryDto } from './dto/feed-query.dto';
|
||||||
|
import { FeedRepository } from './feed.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FeedService {
|
||||||
|
constructor(
|
||||||
|
private readonly feedRepository: FeedRepository,
|
||||||
|
private readonly usersRepository: UsersRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getMyFeed(currentUserId: string, query: FeedQueryDto) {
|
||||||
|
const currentUser = await this.usersRepository.findById(currentUserId);
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new NotFoundException('Current user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const followingOnly = query.followingOnly ?? false;
|
||||||
|
const radiusKm = query.radiusKm ?? 30;
|
||||||
|
|
||||||
|
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
|
||||||
|
const visibleAuthorIds = followingOnly ? [currentUserId, ...followingIds] : null;
|
||||||
|
|
||||||
|
const filter: Record<string, unknown> = {
|
||||||
|
$or: [
|
||||||
|
{ visibility: PostVisibility.PUBLIC },
|
||||||
|
{ authorId: new Types.ObjectId(currentUserId) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (visibleAuthorIds) {
|
||||||
|
filter.authorId = { $in: visibleAuthorIds.map((id) => new Types.ObjectId(id)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||||
|
|
||||||
|
const scored = candidates
|
||||||
|
.filter((post) => {
|
||||||
|
if (!query.preferredPostType) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return post.postType === query.preferredPostType;
|
||||||
|
})
|
||||||
|
.map((post) => ({
|
||||||
|
post,
|
||||||
|
score: this.scorePost({
|
||||||
|
currentUser,
|
||||||
|
currentUserId,
|
||||||
|
followingIds,
|
||||||
|
post,
|
||||||
|
preferredPostType: query.preferredPostType,
|
||||||
|
radiusKm,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.score - a.score ||
|
||||||
|
new Date((b.post as any).createdAt ?? 0).getTime() - new Date((a.post as any).createdAt ?? 0).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = scored.length;
|
||||||
|
const skip = cursorOffset ?? (page - 1) * limit;
|
||||||
|
const items = scored.slice(skip, skip + limit).map((entry) => ({
|
||||||
|
...entry.post.toObject(),
|
||||||
|
feedScore: Number(entry.score.toFixed(3)),
|
||||||
|
}));
|
||||||
|
const nextOffset = skip + items.length;
|
||||||
|
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit) || 1,
|
||||||
|
nextCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrending(query: FeedQueryDto) {
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const skip = cursorOffset ?? (page - 1) * limit;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.feedRepository.findTrendingPublicPosts(skip, limit),
|
||||||
|
this.feedRepository.count({ visibility: PostVisibility.PUBLIC }),
|
||||||
|
]);
|
||||||
|
const nextOffset = skip + items.length;
|
||||||
|
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit) || 1,
|
||||||
|
nextCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private scorePost(input: {
|
||||||
|
currentUser: UserDocument;
|
||||||
|
currentUserId: string;
|
||||||
|
followingIds: string[];
|
||||||
|
post: any;
|
||||||
|
preferredPostType?: PostType;
|
||||||
|
radiusKm: number;
|
||||||
|
}): number {
|
||||||
|
const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input;
|
||||||
|
const author: any = post.authorId;
|
||||||
|
const authorId = typeof author === 'string' ? author : author?._id?.toString?.() ?? '';
|
||||||
|
const isOwnPost = authorId === currentUserId;
|
||||||
|
const isFollowing = followingIds.includes(authorId);
|
||||||
|
|
||||||
|
const ageMs = Date.now() - new Date(post.createdAt).getTime();
|
||||||
|
const ageHours = ageMs / (1000 * 60 * 60);
|
||||||
|
const freshness = Math.max(0, 36 - ageHours);
|
||||||
|
|
||||||
|
const engagement = post.likesCount * 3 + post.commentsCount * 4 + post.savesCount * 5;
|
||||||
|
const hashtagMatches = this.intersectionCount(
|
||||||
|
this.buildPreferenceTokens(currentUser),
|
||||||
|
(post.hashtags ?? []).map((x: string) => x.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const distanceKm = this.computeDistanceKm(
|
||||||
|
currentUser.latitude,
|
||||||
|
currentUser.longitude,
|
||||||
|
author?.latitude ?? null,
|
||||||
|
author?.longitude ?? null,
|
||||||
|
);
|
||||||
|
const nearbyBoost =
|
||||||
|
typeof distanceKm === 'number' && distanceKm <= radiusKm ? Math.max(0, 25 - distanceKm / 2) : 0;
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
score += engagement;
|
||||||
|
score += freshness;
|
||||||
|
score += isOwnPost ? 10 : 0;
|
||||||
|
score += isFollowing ? 40 : 0;
|
||||||
|
score += post.postType === preferredPostType ? 18 : 0;
|
||||||
|
score += hashtagMatches * 9;
|
||||||
|
score += nearbyBoost;
|
||||||
|
score += author?.isVerified ? 8 : 0;
|
||||||
|
score += Math.min(20, Math.floor((author?.followersCount ?? 0) / 200));
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPreferenceTokens(user: UserDocument): string[] {
|
||||||
|
const tokens = [
|
||||||
|
...(user.musicGenres ?? []),
|
||||||
|
...(user.favoriteInstruments ?? []),
|
||||||
|
...(user.favoriteMaqamat ?? []),
|
||||||
|
...(user.musicRoles ?? []),
|
||||||
|
]
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return Array.from(new Set(tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
private intersectionCount(a: string[], b: string[]): number {
|
||||||
|
if (!a.length || !b.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const right = new Set(b);
|
||||||
|
let count = 0;
|
||||||
|
for (const item of a) {
|
||||||
|
if (right.has(item)) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeDistanceKm(
|
||||||
|
lat1: number | null | undefined,
|
||||||
|
lon1: number | null | undefined,
|
||||||
|
lat2: number | null | undefined,
|
||||||
|
lon2: number | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (
|
||||||
|
typeof lat1 !== 'number' ||
|
||||||
|
typeof lon1 !== 'number' ||
|
||||||
|
typeof lat2 !== 'number' ||
|
||||||
|
typeof lon2 !== 'number'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||||
|
const earthKm = 6371;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return earthKm * c;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user