first commit
هذا الالتزام موجود في:
19
src/modules/audit/audit.module.ts
Normal file
19
src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { AuditRepository } from './audit.repository';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{
|
||||
name: AuditLog.name,
|
||||
schema: AuditLogSchema,
|
||||
},
|
||||
]),
|
||||
],
|
||||
providers: [AuditRepository, AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
29
src/modules/audit/audit.repository.ts
Normal file
29
src/modules/audit/audit.repository.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import { AuditLog, AuditLogDocument } from './schemas/audit-log.schema';
|
||||
|
||||
@Injectable()
|
||||
export class AuditRepository {
|
||||
constructor(@InjectModel(AuditLog.name) private readonly auditModel: Model<AuditLogDocument>) {}
|
||||
|
||||
async create(payload: {
|
||||
actorType: 'user' | 'superadmin' | 'system';
|
||||
actorUserId?: string;
|
||||
actorIdentifier?: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
await this.auditModel.create({
|
||||
actorType: payload.actorType,
|
||||
...(payload.actorUserId ? { actorUserId: new Types.ObjectId(payload.actorUserId) } : {}),
|
||||
actorIdentifier: payload.actorIdentifier,
|
||||
action: payload.action,
|
||||
targetType: payload.targetType,
|
||||
targetId: payload.targetId,
|
||||
metadata: payload.metadata ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/modules/audit/audit.service.ts
Normal file
24
src/modules/audit/audit.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuditRepository } from './audit.repository';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly auditRepository: AuditRepository) {}
|
||||
|
||||
async logSuperAdminAction(
|
||||
actorIdentifier: string,
|
||||
action: string,
|
||||
targetType: string,
|
||||
targetId?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.auditRepository.create({
|
||||
actorType: 'superadmin',
|
||||
actorIdentifier,
|
||||
action,
|
||||
targetType,
|
||||
targetId,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
31
src/modules/audit/schemas/audit-log.schema.ts
Normal file
31
src/modules/audit/schemas/audit-log.schema.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
|
||||
export type AuditLogDocument = HydratedDocument<AuditLog>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class AuditLog {
|
||||
@Prop({ required: true, index: true })
|
||||
actorType!: 'user' | 'superadmin' | 'system';
|
||||
|
||||
@Prop({ type: Types.ObjectId, required: false, index: true })
|
||||
actorUserId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: String, required: false, index: true })
|
||||
actorIdentifier?: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
action!: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
targetType!: string;
|
||||
|
||||
@Prop({ type: String, required: false, index: true })
|
||||
targetId?: string;
|
||||
|
||||
@Prop({ type: Object, default: {} })
|
||||
metadata!: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const AuditLogSchema = SchemaFactory.createForClass(AuditLog);
|
||||
AuditLogSchema.index({ createdAt: -1, action: 1 });
|
||||
154
src/modules/auth/auth.controller.ts
Normal file
154
src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { GoogleTokenLoginDto } from './dto/google-token-login.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterBasicDto } from './dto/register-basic.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
||||
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
||||
import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@Throttle(10, 60_000)
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@Post('register-basic')
|
||||
@Throttle(10, 60_000)
|
||||
async registerBasic(@Body() dto: RegisterBasicDto) {
|
||||
return this.authService.registerBasic(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('login')
|
||||
@Throttle(20, 60_000)
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('refresh')
|
||||
@Throttle(30, 60_000)
|
||||
async refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refresh(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('logout')
|
||||
async logout(@Body() dto: RefreshTokenDto): Promise<{ message: string }> {
|
||||
await this.authService.logout(dto);
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('forgot-password')
|
||||
@Throttle(8, 60_000)
|
||||
async forgotPassword(@Body() dto: ForgotPasswordDto) {
|
||||
return this.authService.forgotPassword(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('verify-reset-code')
|
||||
@Throttle(20, 60_000)
|
||||
async verifyResetCode(@Body() dto: VerifyResetCodeDto) {
|
||||
return this.authService.verifyResetCode(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('reset-password')
|
||||
@Throttle(10, 60_000)
|
||||
async resetPassword(@Body() dto: ResetPasswordDto) {
|
||||
return this.authService.resetPassword(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('send-email-verification')
|
||||
@Throttle(8, 60_000)
|
||||
async sendEmailVerification(@Body() dto: SendEmailVerificationDto) {
|
||||
return this.authService.sendEmailVerification(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('verify-email')
|
||||
@Throttle(20, 60_000)
|
||||
async verifyEmail(@Body() dto: VerifyEmailDto) {
|
||||
return this.authService.verifyEmail(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('superadmin/login')
|
||||
@Throttle(10, 60_000)
|
||||
async superAdminLogin(@Body() dto: SuperAdminLoginDto) {
|
||||
return this.authService.superAdminLogin(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('superadmin/refresh')
|
||||
@Throttle(20, 60_000)
|
||||
async superAdminRefresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.superAdminRefresh(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('superadmin/logout')
|
||||
async superAdminLogout(@Body() dto: RefreshTokenDto): Promise<{ message: string }> {
|
||||
await this.authService.superAdminLogout(dto);
|
||||
return { message: 'Superadmin logged out successfully' };
|
||||
}
|
||||
|
||||
@Get('google')
|
||||
@UseGuards(GoogleAuthGuard)
|
||||
async googleAuth(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
@Get('google/callback')
|
||||
@UseGuards(GoogleAuthGuard)
|
||||
async googleCallback(
|
||||
@Req()
|
||||
req: Request & {
|
||||
user: { googleId: string; email: string; name: string; avatar?: string };
|
||||
},
|
||||
) {
|
||||
return this.authService.loginWithGoogle(req.user);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('google/token')
|
||||
@Throttle(20, 60_000)
|
||||
async googleTokenLogin(@Body() dto: GoogleTokenLoginDto) {
|
||||
return this.authService.loginWithGoogleIdToken(dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('sessions')
|
||||
async listMySessions(@CurrentUser() user: JwtPayload) {
|
||||
return this.authService.listUserSessions(user.sub);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('sessions/:jti/revoke')
|
||||
async revokeSession(@CurrentUser() user: JwtPayload, @Param('jti') jti: string) {
|
||||
await this.authService.revokeUserSession(user.sub, jti);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
73
src/modules/auth/auth.module.ts
Normal file
73
src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { EmailModule } from '../email/email.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import { AuthService } from './auth.service';
|
||||
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
||||
import {
|
||||
EmailVerificationCode,
|
||||
EmailVerificationCodeSchema,
|
||||
} from './schemas/email-verification-code.schema';
|
||||
import {
|
||||
PasswordResetCode,
|
||||
PasswordResetCodeSchema,
|
||||
} from './schemas/password-reset-code.schema';
|
||||
import { RefreshToken, RefreshTokenSchema } from './schemas/refresh-token.schema';
|
||||
import {
|
||||
SuperAdminRefreshToken,
|
||||
SuperAdminRefreshTokenSchema,
|
||||
} from './schemas/super-admin-refresh-token.schema';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { SuperAdminJwtStrategy } from './strategies/super-admin-jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('jwt.accessSecret', { infer: true }),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('jwt.accessExpiresIn', { infer: true }),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
MongooseModule.forFeature([
|
||||
{
|
||||
name: RefreshToken.name,
|
||||
schema: RefreshTokenSchema,
|
||||
},
|
||||
{
|
||||
name: SuperAdminRefreshToken.name,
|
||||
schema: SuperAdminRefreshTokenSchema,
|
||||
},
|
||||
{
|
||||
name: PasswordResetCode.name,
|
||||
schema: PasswordResetCodeSchema,
|
||||
},
|
||||
{
|
||||
name: EmailVerificationCode.name,
|
||||
schema: EmailVerificationCodeSchema,
|
||||
},
|
||||
]),
|
||||
EmailModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
AuthRepository,
|
||||
JwtStrategy,
|
||||
JwtRefreshStrategy,
|
||||
GoogleStrategy,
|
||||
GoogleAuthGuard,
|
||||
SuperAdminJwtStrategy,
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
237
src/modules/auth/auth.repository.ts
Normal file
237
src/modules/auth/auth.repository.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import {
|
||||
EmailVerificationCode,
|
||||
EmailVerificationCodeDocument,
|
||||
} from './schemas/email-verification-code.schema';
|
||||
import {
|
||||
PasswordResetCode,
|
||||
PasswordResetCodeDocument,
|
||||
} from './schemas/password-reset-code.schema';
|
||||
import { RefreshToken, RefreshTokenDocument } from './schemas/refresh-token.schema';
|
||||
import {
|
||||
SuperAdminRefreshToken,
|
||||
SuperAdminRefreshTokenDocument,
|
||||
} from './schemas/super-admin-refresh-token.schema';
|
||||
|
||||
@Injectable()
|
||||
export class AuthRepository {
|
||||
constructor(
|
||||
@InjectModel(RefreshToken.name)
|
||||
private readonly refreshTokenModel: Model<RefreshTokenDocument>,
|
||||
@InjectModel(SuperAdminRefreshToken.name)
|
||||
private readonly superAdminRefreshTokenModel: Model<SuperAdminRefreshTokenDocument>,
|
||||
@InjectModel(PasswordResetCode.name)
|
||||
private readonly passwordResetCodeModel: Model<PasswordResetCodeDocument>,
|
||||
@InjectModel(EmailVerificationCode.name)
|
||||
private readonly emailVerificationCodeModel: Model<EmailVerificationCodeDocument>,
|
||||
) {}
|
||||
|
||||
async createRefreshToken(userId: string, jti: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
||||
await this.refreshTokenModel.create({
|
||||
userId: new Types.ObjectId(userId),
|
||||
jti,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
async findActiveUserTokens(userId: string): Promise<RefreshTokenDocument[]> {
|
||||
return this.refreshTokenModel
|
||||
.find({ userId: new Types.ObjectId(userId), revoked: false })
|
||||
.select('+tokenHash')
|
||||
.exec();
|
||||
}
|
||||
|
||||
async revokeAllUserTokens(userId: string): Promise<void> {
|
||||
await this.refreshTokenModel
|
||||
.updateMany({ userId: new Types.ObjectId(userId), revoked: false }, { revoked: true })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async revokeUserTokenByJti(userId: string, jti: string): Promise<void> {
|
||||
await this.refreshTokenModel
|
||||
.updateOne({ userId: new Types.ObjectId(userId), jti, revoked: false }, { revoked: true })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findActiveUserTokenByJti(userId: string, jti: string): Promise<RefreshTokenDocument | null> {
|
||||
return this.refreshTokenModel
|
||||
.findOne({ userId: new Types.ObjectId(userId), jti, revoked: false })
|
||||
.select('+tokenHash')
|
||||
.exec();
|
||||
}
|
||||
|
||||
async markCompromisedAndRevokeAll(userId: string): Promise<void> {
|
||||
await this.refreshTokenModel
|
||||
.updateMany(
|
||||
{ userId: new Types.ObjectId(userId), revoked: false },
|
||||
{ revoked: true, compromised: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async listUserSessions(userId: string): Promise<RefreshTokenDocument[]> {
|
||||
return this.refreshTokenModel
|
||||
.find({ userId: new Types.ObjectId(userId), revoked: false, expiresAt: { $gt: new Date() } })
|
||||
.select('jti expiresAt createdAt')
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async removeExpiredAndRevoked(userId: string): Promise<void> {
|
||||
await this.refreshTokenModel
|
||||
.deleteMany({
|
||||
userId: new Types.ObjectId(userId),
|
||||
$or: [{ revoked: true }, { expiresAt: { $lt: new Date() } }],
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async createSuperAdminRefreshToken(
|
||||
adminEmail: string,
|
||||
tokenHash: string,
|
||||
expiresAt: Date,
|
||||
): Promise<void> {
|
||||
await this.superAdminRefreshTokenModel.create({
|
||||
adminEmail: adminEmail.toLowerCase(),
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
async findActiveSuperAdminTokens(adminEmail: string): Promise<SuperAdminRefreshTokenDocument[]> {
|
||||
return this.superAdminRefreshTokenModel
|
||||
.find({ adminEmail: adminEmail.toLowerCase(), revoked: false })
|
||||
.select('+tokenHash')
|
||||
.exec();
|
||||
}
|
||||
|
||||
async revokeAllSuperAdminTokens(adminEmail: string): Promise<void> {
|
||||
await this.superAdminRefreshTokenModel
|
||||
.updateMany({ adminEmail: adminEmail.toLowerCase(), revoked: false }, { revoked: true })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> {
|
||||
await this.superAdminRefreshTokenModel
|
||||
.deleteMany({
|
||||
adminEmail: adminEmail.toLowerCase(),
|
||||
$or: [{ revoked: true }, { expiresAt: { $lt: new Date() } }],
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async invalidateActivePasswordResetCodes(userId: string): Promise<void> {
|
||||
await this.passwordResetCodeModel
|
||||
.updateMany(
|
||||
{ userId: new Types.ObjectId(userId), used: false, expiresAt: { $gt: new Date() } },
|
||||
{ used: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async createPasswordResetCode(userId: string, codeHash: string, expiresAt: Date): Promise<void> {
|
||||
await this.passwordResetCodeModel.create({
|
||||
userId: new Types.ObjectId(userId),
|
||||
codeHash,
|
||||
expiresAt,
|
||||
attempts: 0,
|
||||
verified: false,
|
||||
used: false,
|
||||
});
|
||||
}
|
||||
|
||||
async findLatestActivePasswordResetCode(userId: string): Promise<PasswordResetCodeDocument | null> {
|
||||
return this.passwordResetCodeModel
|
||||
.findOne({
|
||||
userId: new Types.ObjectId(userId),
|
||||
used: false,
|
||||
expiresAt: { $gt: new Date() },
|
||||
})
|
||||
.select('+codeHash')
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async incrementPasswordResetAttempts(id: string): Promise<void> {
|
||||
await this.passwordResetCodeModel.findByIdAndUpdate(id, { $inc: { attempts: 1 } }).exec();
|
||||
}
|
||||
|
||||
async markPasswordResetCodeVerified(id: string): Promise<void> {
|
||||
await this.passwordResetCodeModel.findByIdAndUpdate(id, { verified: true }).exec();
|
||||
}
|
||||
|
||||
async markPasswordResetCodeUsed(id: string): Promise<void> {
|
||||
await this.passwordResetCodeModel.findByIdAndUpdate(id, { used: true }).exec();
|
||||
}
|
||||
|
||||
async markPasswordResetCodeUsedByUser(userId: string): Promise<void> {
|
||||
await this.passwordResetCodeModel
|
||||
.updateMany({ userId: new Types.ObjectId(userId), used: false }, { used: true })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findValidVerifiedPasswordResetCode(
|
||||
codeId: string,
|
||||
userId: string,
|
||||
): Promise<PasswordResetCodeDocument | null> {
|
||||
return this.passwordResetCodeModel
|
||||
.findOne({
|
||||
_id: new Types.ObjectId(codeId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
used: false,
|
||||
verified: true,
|
||||
expiresAt: { $gt: new Date() },
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async invalidateActiveEmailVerificationCodes(userId: string): Promise<void> {
|
||||
await this.emailVerificationCodeModel
|
||||
.updateMany(
|
||||
{ userId: new Types.ObjectId(userId), used: false, expiresAt: { $gt: new Date() } },
|
||||
{ used: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async createEmailVerificationCode(userId: string, codeHash: string, expiresAt: Date): Promise<void> {
|
||||
await this.emailVerificationCodeModel.create({
|
||||
userId: new Types.ObjectId(userId),
|
||||
codeHash,
|
||||
expiresAt,
|
||||
attempts: 0,
|
||||
used: false,
|
||||
});
|
||||
}
|
||||
|
||||
async findLatestActiveEmailVerificationCode(
|
||||
userId: string,
|
||||
): Promise<EmailVerificationCodeDocument | null> {
|
||||
return this.emailVerificationCodeModel
|
||||
.findOne({
|
||||
userId: new Types.ObjectId(userId),
|
||||
used: false,
|
||||
expiresAt: { $gt: new Date() },
|
||||
})
|
||||
.select('+codeHash')
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async incrementEmailVerificationAttempts(id: string): Promise<void> {
|
||||
await this.emailVerificationCodeModel.findByIdAndUpdate(id, { $inc: { attempts: 1 } }).exec();
|
||||
}
|
||||
|
||||
async markEmailVerificationCodeUsed(id: string): Promise<void> {
|
||||
await this.emailVerificationCodeModel.findByIdAndUpdate(id, { used: true }).exec();
|
||||
}
|
||||
|
||||
async markAllEmailVerificationCodesUsedByUser(userId: string): Promise<void> {
|
||||
await this.emailVerificationCodeModel
|
||||
.updateMany({ userId: new Types.ObjectId(userId), used: false }, { used: true })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
666
src/modules/auth/auth.service.ts
Normal file
666
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomBytes, randomInt, randomUUID } from 'crypto';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { compareHash, hashValue } from '../../common/utils/hash.util';
|
||||
import { EmailService } from '../email/email.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { GoogleTokenLoginDto } from './dto/google-token-login.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterBasicDto } from './dto/register-basic.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
||||
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
||||
import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import { AuthResult, TokenPair } from './types/token-pair.type';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly googleOAuthClient = new OAuth2Client();
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly authRepository: AuthRepository,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto): Promise<{ message: string; email: string; debugCode?: string }> {
|
||||
if (dto.password !== dto.confirmPassword) {
|
||||
throw new BadRequestException('Password confirmation does not match');
|
||||
}
|
||||
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const passwordHash = await hashValue(dto.password, saltRounds);
|
||||
const generatedUsername = dto.username ?? (await this.generateUniqueUsernameFromEmail(dto.email));
|
||||
const resolvedName = dto.name ?? dto.stageName ?? generatedUsername;
|
||||
|
||||
const { confirmPassword: _, ...registerPayload } = dto;
|
||||
const user = await this.usersService.create({
|
||||
...registerPayload,
|
||||
name: resolvedName,
|
||||
username: generatedUsername,
|
||||
password: passwordHash,
|
||||
});
|
||||
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
||||
const response: { message: string; email: string; debugCode?: string } = {
|
||||
message: 'Registration successful. Verify your email with the code sent.',
|
||||
email: user.email,
|
||||
};
|
||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||
if (nodeEnv !== 'production') {
|
||||
response.debugCode = code;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> {
|
||||
if (dto.password !== dto.confirmPassword) {
|
||||
throw new BadRequestException('Password confirmation does not match');
|
||||
}
|
||||
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const passwordHash = await hashValue(dto.password, saltRounds);
|
||||
const generatedUsername = await this.generateUniqueUsernameFromEmail(dto.email);
|
||||
|
||||
const user = await this.usersService.create({
|
||||
name: generatedUsername,
|
||||
username: generatedUsername,
|
||||
email: dto.email,
|
||||
password: passwordHash,
|
||||
});
|
||||
|
||||
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
||||
const response: { message: string; email: string; debugCode?: string } = {
|
||||
message: 'Registration successful. Verify your email with the code sent.',
|
||||
email: user.email,
|
||||
};
|
||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||
if (nodeEnv !== 'production') {
|
||||
response.debugCode = code;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async login(dto: LoginDto): Promise<AuthResult> {
|
||||
const user = await this.usersService.findByEmailWithPassword(dto.email);
|
||||
if (!user || !user.password) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
if (user.isDisabled) {
|
||||
throw new ForbiddenException('Account is disabled');
|
||||
}
|
||||
if (!user.isVerified) {
|
||||
throw new ForbiddenException('Email not verified');
|
||||
}
|
||||
|
||||
const isMatch = await compareHash(dto.password, user.password);
|
||||
if (!isMatch) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
||||
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||
return { ...tokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
async sendEmailVerification(
|
||||
dto: SendEmailVerificationDto,
|
||||
): Promise<{ message: string; debugCode?: string }> {
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||
const message = 'If this email exists, a verification code was sent';
|
||||
if (!user || user.isDisabled) {
|
||||
return { message };
|
||||
}
|
||||
if (user.isVerified) {
|
||||
return { message: 'Email is already verified' };
|
||||
}
|
||||
|
||||
const code = await this.issueEmailVerificationCode(user.id, user.email);
|
||||
const response: { message: string; debugCode?: string } = { message };
|
||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||
if (nodeEnv !== 'production') {
|
||||
response.debugCode = code;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async verifyEmail(dto: VerifyEmailDto): Promise<AuthResult & { message: string }> {
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||
if (!user || user.isDisabled) {
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
if (user.isVerified) {
|
||||
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
||||
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||
return {
|
||||
message: 'Email already verified',
|
||||
...tokens,
|
||||
user: safeUser.toObject() as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
const codeRecord = await this.authRepository.findLatestActiveEmailVerificationCode(user.id);
|
||||
if (!codeRecord) {
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
const maxAttempts = this.configService.get<number>('emailVerification.maxAttempts', { infer: true });
|
||||
if (codeRecord.attempts >= maxAttempts) {
|
||||
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
||||
throw new UnauthorizedException('Verification code attempts exceeded');
|
||||
}
|
||||
|
||||
const isMatch = await compareHash(dto.code, codeRecord.codeHash);
|
||||
if (!isMatch) {
|
||||
await this.authRepository.incrementEmailVerificationAttempts(codeRecord.id);
|
||||
if (codeRecord.attempts + 1 >= maxAttempts) {
|
||||
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
||||
}
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
await this.usersService.markEmailVerified(user.id);
|
||||
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
|
||||
await this.authRepository.markAllEmailVerificationCodesUsedByUser(user.id);
|
||||
|
||||
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||
const tokens = await this.generateAndStoreTokenPair(safeUser.id, safeUser.username, safeUser.role ?? 'user');
|
||||
|
||||
return {
|
||||
message: 'Email verified successfully',
|
||||
...tokens,
|
||||
user: safeUser.toObject() as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(dto: RefreshTokenDto): Promise<AuthResult> {
|
||||
const decoded = this.jwtService.verify<{ sub: string; username: string; tokenType: string; jti?: string }>(
|
||||
dto.refreshToken,
|
||||
{
|
||||
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
|
||||
},
|
||||
);
|
||||
|
||||
if (decoded.tokenType !== 'refresh' || !decoded.jti) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const tokenRecord = await this.authRepository.findActiveUserTokenByJti(decoded.sub, decoded.jti);
|
||||
if (!tokenRecord) {
|
||||
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
|
||||
throw new UnauthorizedException('Refresh token reuse detected');
|
||||
}
|
||||
|
||||
const isMatch = await compareHash(dto.refreshToken, tokenRecord.tokenHash);
|
||||
if (!isMatch) {
|
||||
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
|
||||
throw new UnauthorizedException('Refresh token reuse detected');
|
||||
}
|
||||
|
||||
const safeUser = await this.usersService.findByIdOrFail(decoded.sub);
|
||||
if (safeUser.isDisabled) {
|
||||
throw new ForbiddenException('Account is disabled');
|
||||
}
|
||||
|
||||
await this.authRepository.revokeUserTokenByJti(decoded.sub, decoded.jti);
|
||||
const authTokens = await this.generateAndStoreTokenPair(
|
||||
decoded.sub,
|
||||
safeUser.username,
|
||||
safeUser.role,
|
||||
);
|
||||
return { ...authTokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
async logout(dto: RefreshTokenDto): Promise<void> {
|
||||
try {
|
||||
const decoded = this.jwtService.verify<{ sub: string }>(dto.refreshToken, {
|
||||
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
|
||||
});
|
||||
await this.authRepository.revokeAllUserTokens(decoded.sub);
|
||||
await this.authRepository.removeExpiredAndRevoked(decoded.sub);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
async loginWithGoogle(googleUser: {
|
||||
googleId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}): Promise<AuthResult> {
|
||||
let user = await this.usersService.findByGoogleId(googleUser.googleId);
|
||||
|
||||
if (!user) {
|
||||
user = await this.usersService.findByEmail(googleUser.email);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const generatedUsername = await this.generateUniqueUsernameFromEmail(googleUser.email);
|
||||
const randomPassword = randomBytes(24).toString('hex');
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const passwordHash = await hashValue(randomPassword, saltRounds);
|
||||
|
||||
user = await this.usersService.create({
|
||||
name: googleUser.name,
|
||||
username: generatedUsername,
|
||||
email: googleUser.email,
|
||||
password: passwordHash,
|
||||
avatar: googleUser.avatar ?? '',
|
||||
isVerified: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.googleId) {
|
||||
user = await this.usersService.linkGoogleAccount(user.id, googleUser.googleId, googleUser.avatar);
|
||||
}
|
||||
|
||||
if (user.isDisabled) {
|
||||
throw new ForbiddenException('Account is disabled');
|
||||
}
|
||||
|
||||
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
|
||||
const safeUser = await this.usersService.findByIdOrFail(user.id);
|
||||
return { ...tokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
async loginWithGoogleIdToken(dto: GoogleTokenLoginDto): Promise<AuthResult> {
|
||||
const clientId = this.configService.get<string>('google.clientId', { infer: true });
|
||||
if (!clientId) {
|
||||
throw new BadRequestException('Google client is not configured');
|
||||
}
|
||||
|
||||
let payload:
|
||||
| {
|
||||
sub?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
}
|
||||
| undefined;
|
||||
try {
|
||||
const ticket = await this.googleOAuthClient.verifyIdToken({
|
||||
idToken: dto.idToken,
|
||||
audience: clientId,
|
||||
});
|
||||
payload = ticket.getPayload();
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid Google id token');
|
||||
}
|
||||
|
||||
if (!payload?.sub || !payload.email || payload.email_verified !== true) {
|
||||
throw new UnauthorizedException('Google account data is invalid');
|
||||
}
|
||||
|
||||
return this.loginWithGoogle({
|
||||
googleId: payload.sub,
|
||||
email: payload.email.toLowerCase(),
|
||||
name: payload.name ?? payload.email.split('@')[0],
|
||||
avatar: payload.picture,
|
||||
});
|
||||
}
|
||||
|
||||
async superAdminLogin(dto: SuperAdminLoginDto): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
superAdmin: { email: string };
|
||||
}> {
|
||||
const configuredEmail = this.configService.get<string>('superAdmin.email', { infer: true });
|
||||
const configuredPassword = this.configService.get<string>('superAdmin.password', { infer: true });
|
||||
|
||||
if (
|
||||
!configuredEmail ||
|
||||
!configuredPassword ||
|
||||
dto.email.toLowerCase() !== configuredEmail.toLowerCase() ||
|
||||
dto.password !== configuredPassword
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid superadmin credentials');
|
||||
}
|
||||
|
||||
const tokens = await this.generateAndStoreSuperAdminTokenPair(configuredEmail);
|
||||
return { ...tokens, superAdmin: { email: configuredEmail } };
|
||||
}
|
||||
|
||||
async superAdminRefresh(dto: RefreshTokenDto): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
superAdmin: { email: string };
|
||||
}> {
|
||||
const decoded = this.jwtService.verify<{ email: string; tokenType: string }>(dto.refreshToken, {
|
||||
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||
});
|
||||
|
||||
if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email) {
|
||||
throw new UnauthorizedException('Invalid superadmin refresh token');
|
||||
}
|
||||
|
||||
const activeTokens = await this.authRepository.findActiveSuperAdminTokens(decoded.email);
|
||||
if (!activeTokens.length) {
|
||||
throw new UnauthorizedException('Invalid superadmin refresh token');
|
||||
}
|
||||
|
||||
let validTokenFound = false;
|
||||
for (const token of activeTokens) {
|
||||
const isMatch = await compareHash(dto.refreshToken, token.tokenHash);
|
||||
if (isMatch) {
|
||||
validTokenFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!validTokenFound) {
|
||||
throw new UnauthorizedException('Invalid superadmin refresh token');
|
||||
}
|
||||
|
||||
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||
const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email);
|
||||
return { ...tokens, superAdmin: { email: decoded.email } };
|
||||
}
|
||||
|
||||
async superAdminLogout(dto: RefreshTokenDto): Promise<void> {
|
||||
try {
|
||||
const decoded = this.jwtService.verify<{ email: string }>(dto.refreshToken, {
|
||||
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||
});
|
||||
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
|
||||
await this.authRepository.removeExpiredAndRevokedSuperAdmin(decoded.email);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid superadmin refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
async listUserSessions(userId: string): Promise<{ items: Array<{ jti: string; createdAt: Date; expiresAt: Date }> }> {
|
||||
const sessions = await this.authRepository.listUserSessions(userId);
|
||||
return {
|
||||
items: sessions.map((s) => ({
|
||||
jti: s.jti,
|
||||
createdAt: (s as unknown as { createdAt: Date }).createdAt,
|
||||
expiresAt: s.expiresAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async revokeUserSession(userId: string, jti: string): Promise<void> {
|
||||
await this.authRepository.revokeUserTokenByJti(userId, jti);
|
||||
}
|
||||
|
||||
async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string; debugCode?: string }> {
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||
const message = 'If this email exists, a reset code was sent';
|
||||
|
||||
if (!user || user.isDisabled) {
|
||||
return { message };
|
||||
}
|
||||
|
||||
const code = this.generateResetCode();
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const codeHash = await hashValue(code, saltRounds);
|
||||
const expiresMinutes = this.configService.get<number>('passwordReset.codeExpiresMinutes', {
|
||||
infer: true,
|
||||
});
|
||||
const expiresAt = new Date(Date.now() + expiresMinutes * 60 * 1000);
|
||||
|
||||
await this.authRepository.invalidateActivePasswordResetCodes(user.id);
|
||||
await this.authRepository.createPasswordResetCode(user.id, codeHash, expiresAt);
|
||||
|
||||
await this.emailService.sendPasswordResetCode(normalizedEmail, code, expiresMinutes);
|
||||
this.logger.log(`Password reset code generated for ${normalizedEmail}`);
|
||||
|
||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||
if (nodeEnv !== 'production') {
|
||||
return { message, debugCode: code };
|
||||
}
|
||||
|
||||
return { message };
|
||||
}
|
||||
|
||||
async verifyResetCode(
|
||||
dto: VerifyResetCodeDto,
|
||||
): Promise<{ resetToken: string; expiresIn: string }> {
|
||||
const normalizedEmail = dto.email.toLowerCase();
|
||||
const user = await this.usersService.findByEmail(normalizedEmail);
|
||||
if (!user || user.isDisabled) {
|
||||
throw new UnauthorizedException('Invalid or expired reset code');
|
||||
}
|
||||
|
||||
const codeRecord = await this.authRepository.findLatestActivePasswordResetCode(user.id);
|
||||
if (!codeRecord) {
|
||||
throw new UnauthorizedException('Invalid or expired reset code');
|
||||
}
|
||||
|
||||
const maxAttempts = this.configService.get<number>('passwordReset.maxAttempts', { infer: true });
|
||||
if (codeRecord.attempts >= maxAttempts) {
|
||||
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
|
||||
throw new UnauthorizedException('Reset code attempts exceeded');
|
||||
}
|
||||
|
||||
const isMatch = await compareHash(dto.code, codeRecord.codeHash);
|
||||
if (!isMatch) {
|
||||
await this.authRepository.incrementPasswordResetAttempts(codeRecord.id);
|
||||
const attemptsAfter = codeRecord.attempts + 1;
|
||||
if (attemptsAfter >= maxAttempts) {
|
||||
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
|
||||
}
|
||||
throw new UnauthorizedException('Invalid or expired reset code');
|
||||
}
|
||||
|
||||
await this.authRepository.markPasswordResetCodeVerified(codeRecord.id);
|
||||
const resetTokenExpiresIn =
|
||||
this.configService.get<string>('passwordReset.tokenExpiresIn', {
|
||||
infer: true,
|
||||
}) ?? '15m';
|
||||
const resetToken = await this.jwtService.signAsync(
|
||||
{ sub: user.id, tokenType: 'password_reset', prcId: codeRecord.id },
|
||||
{
|
||||
secret: this.configService.get<string>('passwordReset.tokenSecret', { infer: true }),
|
||||
expiresIn: resetTokenExpiresIn,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
resetToken,
|
||||
expiresIn: resetTokenExpiresIn,
|
||||
};
|
||||
}
|
||||
|
||||
async resetPassword(dto: ResetPasswordDto): Promise<{ message: string }> {
|
||||
if (dto.newPassword !== dto.confirmPassword) {
|
||||
throw new BadRequestException('Password confirmation does not match');
|
||||
}
|
||||
|
||||
let decoded: { sub: string; tokenType: string; prcId: string };
|
||||
try {
|
||||
decoded = this.jwtService.verify(dto.resetToken, {
|
||||
secret: this.configService.get<string>('passwordReset.tokenSecret', { infer: true }),
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
if (decoded.tokenType !== 'password_reset' || !decoded.prcId || !decoded.sub) {
|
||||
throw new UnauthorizedException('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
const codeRecord = await this.authRepository.findValidVerifiedPasswordResetCode(
|
||||
decoded.prcId,
|
||||
decoded.sub,
|
||||
);
|
||||
if (!codeRecord) {
|
||||
throw new UnauthorizedException('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findByIdOrFail(decoded.sub);
|
||||
if (user.isDisabled) {
|
||||
throw new ForbiddenException('Account is disabled');
|
||||
}
|
||||
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const passwordHash = await hashValue(dto.newPassword, saltRounds);
|
||||
await this.usersService.updatePassword(decoded.sub, passwordHash);
|
||||
|
||||
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
|
||||
await this.authRepository.markPasswordResetCodeUsedByUser(decoded.sub);
|
||||
await this.authRepository.revokeAllUserTokens(decoded.sub);
|
||||
await this.authRepository.removeExpiredAndRevoked(decoded.sub);
|
||||
|
||||
return { message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
private async generateAndStoreTokenPair(
|
||||
userId: string,
|
||||
username: string,
|
||||
role: string,
|
||||
): Promise<TokenPair> {
|
||||
const refreshJti = randomUUID();
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(
|
||||
{ sub: userId, username, role, tokenType: 'access' },
|
||||
{
|
||||
secret: this.configService.get<string>('jwt.accessSecret', { infer: true }),
|
||||
expiresIn: this.configService.get<string>('jwt.accessExpiresIn', { infer: true }),
|
||||
},
|
||||
),
|
||||
this.jwtService.signAsync(
|
||||
{ sub: userId, username, role, tokenType: 'refresh', jti: refreshJti },
|
||||
{
|
||||
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
|
||||
expiresIn: this.configService.get<string>('jwt.refreshExpiresIn', { infer: true }),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const tokenHash = await hashValue(refreshToken, saltRounds);
|
||||
|
||||
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn', {
|
||||
infer: true,
|
||||
});
|
||||
const refreshExpiresInMs = this.parseExpiresInToMs(refreshExpiresIn ?? '30d');
|
||||
|
||||
await this.authRepository.createRefreshToken(userId, refreshJti, tokenHash, new Date(Date.now() + refreshExpiresInMs));
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
private async generateAndStoreSuperAdminTokenPair(adminEmail: string): Promise<TokenPair> {
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(
|
||||
{
|
||||
sub: 'superadmin',
|
||||
username: 'superadmin',
|
||||
email: adminEmail.toLowerCase(),
|
||||
role: 'superadmin',
|
||||
tokenType: 'superadmin_access',
|
||||
},
|
||||
{
|
||||
secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }),
|
||||
expiresIn: this.configService.get<string>('superAdmin.accessExpiresIn', { infer: true }),
|
||||
},
|
||||
),
|
||||
this.jwtService.signAsync(
|
||||
{
|
||||
sub: 'superadmin',
|
||||
username: 'superadmin',
|
||||
email: adminEmail.toLowerCase(),
|
||||
role: 'superadmin',
|
||||
tokenType: 'superadmin_refresh',
|
||||
},
|
||||
{
|
||||
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
|
||||
expiresIn: this.configService.get<string>('superAdmin.refreshExpiresIn', { infer: true }),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const tokenHash = await hashValue(refreshToken, saltRounds);
|
||||
const refreshExpiresIn = this.configService.get<string>('superAdmin.refreshExpiresIn', {
|
||||
infer: true,
|
||||
});
|
||||
const refreshExpiresInMs = this.parseExpiresInToMs(refreshExpiresIn ?? '30d');
|
||||
|
||||
await this.authRepository.createSuperAdminRefreshToken(
|
||||
adminEmail,
|
||||
tokenHash,
|
||||
new Date(Date.now() + refreshExpiresInMs),
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
private parseExpiresInToMs(expiresIn: string): number {
|
||||
const regex = /^(\d+)([smhd])$/;
|
||||
const match = expiresIn.match(regex);
|
||||
if (!match) {
|
||||
return 30 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
const value = Number(match[1]);
|
||||
const unit = match[2];
|
||||
|
||||
const multipliers: Record<string, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
return value * multipliers[unit];
|
||||
}
|
||||
|
||||
private async generateUniqueUsernameFromEmail(email: string): Promise<string> {
|
||||
const base = email.split('@')[0].replace(/[^a-zA-Z0-9_.]/g, '').toLowerCase() || 'user';
|
||||
let candidate = base.slice(0, 24);
|
||||
let counter = 1;
|
||||
|
||||
while (await this.usersService.findByUsername(candidate)) {
|
||||
const suffix = `_${counter}`;
|
||||
const maxBaseLength = 30 - suffix.length;
|
||||
candidate = `${base.slice(0, Math.max(1, maxBaseLength))}${suffix}`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private generateResetCode(): string {
|
||||
return String(randomInt(100000, 1000000));
|
||||
}
|
||||
|
||||
private async issueEmailVerificationCode(userId: string, email: string): Promise<string> {
|
||||
const code = this.generateResetCode();
|
||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||
const codeHash = await hashValue(code, saltRounds);
|
||||
const expiresMinutes = this.configService.get<number>('emailVerification.codeExpiresMinutes', {
|
||||
infer: true,
|
||||
});
|
||||
const expiresAt = new Date(Date.now() + expiresMinutes * 60 * 1000);
|
||||
|
||||
await this.authRepository.invalidateActiveEmailVerificationCodes(userId);
|
||||
await this.authRepository.createEmailVerificationCode(userId, codeHash, expiresAt);
|
||||
|
||||
await this.emailService.sendVerificationCode(email, code, expiresMinutes);
|
||||
this.logger.log(`Email verification code generated for ${email}`);
|
||||
return code;
|
||||
}
|
||||
}
|
||||
16
src/modules/auth/dto/auth-response.dto.ts
Normal file
16
src/modules/auth/dto/auth-response.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AuthResponseDto {
|
||||
@ApiProperty()
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Full user profile without password',
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
})
|
||||
user!: Record<string, unknown>;
|
||||
}
|
||||
8
src/modules/auth/dto/forgot-password.dto.ts
Normal file
8
src/modules/auth/dto/forgot-password.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class ForgotPasswordDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
9
src/modules/auth/dto/google-token-login.dto.ts
Normal file
9
src/modules/auth/dto/google-token-login.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class GoogleTokenLoginDto {
|
||||
@ApiProperty({ description: 'Google ID token from frontend Google Sign-In' })
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
idToken!: string;
|
||||
}
|
||||
13
src/modules/auth/dto/login.dto.ts
Normal file
13
src/modules/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, Length } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'john@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ minLength: 8 })
|
||||
@IsString()
|
||||
@Length(8, 64)
|
||||
password!: string;
|
||||
}
|
||||
9
src/modules/auth/dto/refresh-token.dto.ts
Normal file
9
src/modules/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
refreshToken!: string;
|
||||
}
|
||||
18
src/modules/auth/dto/register-basic.dto.ts
Normal file
18
src/modules/auth/dto/register-basic.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, Length } from 'class-validator';
|
||||
|
||||
export class RegisterBasicDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ minLength: 8, example: 'StrongPass123!' })
|
||||
@IsString()
|
||||
@Length(8, 64)
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ minLength: 8, example: 'StrongPass123!' })
|
||||
@IsString()
|
||||
@Length(8, 64)
|
||||
confirmPassword!: string;
|
||||
}
|
||||
112
src/modules/auth/dto/register.dto.ts
Normal file
112
src/modules/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
Max,
|
||||
Matches,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
||||
import { MusicRole } from '../../../common/enums/music-role.enum';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'john@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ minLength: 8 })
|
||||
@IsString()
|
||||
@Length(8, 64)
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ minLength: 8 })
|
||||
@IsString()
|
||||
@Length(8, 64)
|
||||
confirmPassword!: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'John Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(2, 80)
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'john_doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 30)
|
||||
@Matches(/^[a-zA-Z0-9_.]+$/, { message: 'username can contain letters, numbers, _ and .' })
|
||||
username?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'Artist One' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 80)
|
||||
stageName?: string;
|
||||
|
||||
@ApiProperty({ required: false, maxLength: 160 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 160)
|
||||
bio?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'Riyadh, Saudi Arabia' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 120)
|
||||
location?: string;
|
||||
|
||||
@ApiProperty({ example: 24.7136, minimum: -90, maximum: 90 })
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty({ example: 46.6753, minimum: -180, maximum: 180 })
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrivate?: boolean;
|
||||
|
||||
@ApiProperty({ required: false, enum: MusicRole, isArray: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(MusicRole, { each: true })
|
||||
musicRoles?: MusicRole[];
|
||||
|
||||
@ApiProperty({ required: false, enum: ExperienceLevel, example: ExperienceLevel.BEGINNER })
|
||||
@IsOptional()
|
||||
@IsEnum(ExperienceLevel)
|
||||
experienceLevel?: ExperienceLevel;
|
||||
|
||||
@ApiProperty({ required: false, type: [String], example: ['Tarab', 'Pop'] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
musicGenres?: string[];
|
||||
|
||||
@ApiProperty({ required: false, type: [String], example: ['Oud', 'Piano'] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
favoriteInstruments?: string[];
|
||||
|
||||
@ApiProperty({ required: false, type: [String], example: ['Bayati', 'Rast'] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
favoriteMaqamat?: string[];
|
||||
}
|
||||
18
src/modules/auth/dto/reset-password.dto.ts
Normal file
18
src/modules/auth/dto/reset-password.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Length } from 'class-validator';
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
resetToken!: string;
|
||||
|
||||
@ApiProperty({ minLength: 8, example: 'NewStrongPass123!' })
|
||||
@IsString()
|
||||
@Length(8, 64)
|
||||
newPassword!: string;
|
||||
|
||||
@ApiProperty({ minLength: 8, example: 'NewStrongPass123!' })
|
||||
@IsString()
|
||||
@Length(8, 64)
|
||||
confirmPassword!: string;
|
||||
}
|
||||
8
src/modules/auth/dto/send-email-verification.dto.ts
Normal file
8
src/modules/auth/dto/send-email-verification.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class SendEmailVerificationDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
13
src/modules/auth/dto/super-admin-login.dto.ts
Normal file
13
src/modules/auth/dto/super-admin-login.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, Length } from 'class-validator';
|
||||
|
||||
export class SuperAdminLoginDto {
|
||||
@ApiProperty({ example: 'admin@oudelaa.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: 'SuperAdminStrongPass123!' })
|
||||
@IsString()
|
||||
@Length(8, 128)
|
||||
password!: string;
|
||||
}
|
||||
14
src/modules/auth/dto/verify-email.dto.ts
Normal file
14
src/modules/auth/dto/verify-email.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class VerifyEmailDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
@Matches(/^\d{6}$/, { message: 'code must be 6 digits' })
|
||||
code!: string;
|
||||
}
|
||||
14
src/modules/auth/dto/verify-reset-code.dto.ts
Normal file
14
src/modules/auth/dto/verify-reset-code.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class VerifyResetCodeDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
@Matches(/^\d{6}$/, { message: 'code must be 6 digits' })
|
||||
code!: string;
|
||||
}
|
||||
5
src/modules/auth/guards/google-auth.guard.ts
Normal file
5
src/modules/auth/guards/google-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleAuthGuard extends AuthGuard('google') {}
|
||||
28
src/modules/auth/schemas/email-verification-code.schema.ts
Normal file
28
src/modules/auth/schemas/email-verification-code.schema.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type EmailVerificationCodeDocument = HydratedDocument<EmailVerificationCode>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class EmailVerificationCode {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
userId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true, select: false })
|
||||
codeHash!: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
expiresAt!: Date;
|
||||
|
||||
@Prop({ default: 0, min: 0 })
|
||||
attempts!: number;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
used!: boolean;
|
||||
}
|
||||
|
||||
export const EmailVerificationCodeSchema = SchemaFactory.createForClass(EmailVerificationCode);
|
||||
|
||||
EmailVerificationCodeSchema.index({ userId: 1, used: 1, expiresAt: -1 });
|
||||
EmailVerificationCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
31
src/modules/auth/schemas/password-reset-code.schema.ts
Normal file
31
src/modules/auth/schemas/password-reset-code.schema.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type PasswordResetCodeDocument = HydratedDocument<PasswordResetCode>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class PasswordResetCode {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
userId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true, select: false })
|
||||
codeHash!: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
expiresAt!: Date;
|
||||
|
||||
@Prop({ default: 0, min: 0 })
|
||||
attempts!: number;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
verified!: boolean;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
used!: boolean;
|
||||
}
|
||||
|
||||
export const PasswordResetCodeSchema = SchemaFactory.createForClass(PasswordResetCode);
|
||||
|
||||
PasswordResetCodeSchema.index({ userId: 1, used: 1, expiresAt: -1 });
|
||||
PasswordResetCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
32
src/modules/auth/schemas/refresh-token.schema.ts
Normal file
32
src/modules/auth/schemas/refresh-token.schema.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type RefreshTokenDocument = HydratedDocument<RefreshToken>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class RefreshToken {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
userId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true, select: false })
|
||||
tokenHash!: string;
|
||||
|
||||
@Prop({ required: true, index: true, unique: true })
|
||||
jti!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
expiresAt!: Date;
|
||||
|
||||
@Prop({ default: false })
|
||||
revoked!: boolean;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
compromised!: boolean;
|
||||
}
|
||||
|
||||
export const RefreshTokenSchema = SchemaFactory.createForClass(RefreshToken);
|
||||
|
||||
RefreshTokenSchema.index({ userId: 1, revoked: 1 });
|
||||
RefreshTokenSchema.index({ userId: 1, jti: 1 });
|
||||
RefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
23
src/modules/auth/schemas/super-admin-refresh-token.schema.ts
Normal file
23
src/modules/auth/schemas/super-admin-refresh-token.schema.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument } from 'mongoose';
|
||||
|
||||
export type SuperAdminRefreshTokenDocument = HydratedDocument<SuperAdminRefreshToken>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class SuperAdminRefreshToken {
|
||||
@Prop({ required: true, trim: true, lowercase: true, index: true })
|
||||
adminEmail!: string;
|
||||
|
||||
@Prop({ required: true, select: false })
|
||||
tokenHash!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
expiresAt!: Date;
|
||||
|
||||
@Prop({ default: false })
|
||||
revoked!: boolean;
|
||||
}
|
||||
|
||||
export const SuperAdminRefreshTokenSchema = SchemaFactory.createForClass(SuperAdminRefreshToken);
|
||||
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, revoked: 1 });
|
||||
SuperAdminRefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
42
src/modules/auth/strategies/google.strategy.ts
Normal file
42
src/modules/auth/strategies/google.strategy.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
clientID:
|
||||
configService.get<string>('google.clientId', { infer: true }) || 'missing-google-client-id',
|
||||
clientSecret:
|
||||
configService.get<string>('google.clientSecret', { infer: true }) ||
|
||||
'missing-google-client-secret',
|
||||
callbackURL:
|
||||
configService.get<string>('google.callbackUrl', { infer: true }) ||
|
||||
'http://localhost:4000/api/v1/auth/google/callback',
|
||||
scope: ['email', 'profile'],
|
||||
});
|
||||
}
|
||||
|
||||
validate(
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
profile: Profile,
|
||||
done: VerifyCallback,
|
||||
): void {
|
||||
const email = profile.emails?.[0]?.value?.toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
done(new UnauthorizedException('Google account email is not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
done(null, {
|
||||
googleId: profile.id,
|
||||
email,
|
||||
name: profile.displayName ?? 'Google User',
|
||||
avatar: profile.photos?.[0]?.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
26
src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
26
src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('jwt.refreshSecret', { infer: true }),
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
validate(_: Request, payload: JwtPayload): JwtPayload {
|
||||
if (payload.tokenType !== 'refresh') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
24
src/modules/auth/strategies/jwt.strategy.ts
Normal file
24
src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('jwt.accessSecret', { infer: true }),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): JwtPayload {
|
||||
if (payload.tokenType !== 'access') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
24
src/modules/auth/strategies/super-admin-jwt.strategy.ts
Normal file
24
src/modules/auth/strategies/super-admin-jwt.strategy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SuperAdminJwtStrategy extends PassportStrategy(Strategy, 'superadmin-jwt') {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('superAdmin.accessSecret', { infer: true }),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): JwtPayload {
|
||||
if (payload.tokenType !== 'superadmin_access') {
|
||||
throw new UnauthorizedException('Invalid superadmin token');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
8
src/modules/auth/types/token-pair.type.ts
Normal file
8
src/modules/auth/types/token-pair.type.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type TokenPair = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export type AuthResult = TokenPair & {
|
||||
user: Record<string, unknown>;
|
||||
};
|
||||
78
src/modules/chat/chat.controller.ts
Normal file
78
src/modules/chat/chat.controller.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { ChatService } from './chat.service';
|
||||
import { CreateConversationDto } from './dto/create-conversation.dto';
|
||||
import { MessageQueryDto } from './dto/message-query.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
|
||||
@ApiTags('Chat')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
constructor(private readonly chatService: ChatService) {}
|
||||
|
||||
@Post('conversations')
|
||||
@Throttle(40, 60_000)
|
||||
async createConversation(@CurrentUser() user: JwtPayload, @Body() dto: CreateConversationDto) {
|
||||
return this.chatService.createConversation(user.sub, dto);
|
||||
}
|
||||
|
||||
@Get('conversations')
|
||||
async myConversations(@CurrentUser() user: JwtPayload, @Query() query: MessageQueryDto) {
|
||||
return this.chatService.getMyConversations(user.sub, query);
|
||||
}
|
||||
|
||||
@Get('conversations/:conversationId/messages')
|
||||
async messages(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('conversationId') conversationId: string,
|
||||
@Query() query: MessageQueryDto,
|
||||
) {
|
||||
return this.chatService.getMessages(user.sub, conversationId, query);
|
||||
}
|
||||
|
||||
@Post('messages')
|
||||
@Throttle(120, 60_000)
|
||||
async sendMessage(@CurrentUser() user: JwtPayload, @Body() dto: SendMessageDto) {
|
||||
return this.chatService.sendMessage(user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('messages/:messageId/seen')
|
||||
@Throttle(200, 60_000)
|
||||
async markSeen(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
|
||||
return this.chatService.markMessageSeen(user.sub, messageId);
|
||||
}
|
||||
|
||||
@Patch('messages/:messageId/unsend')
|
||||
@Throttle(80, 60_000)
|
||||
async unsend(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
|
||||
return this.chatService.unsendMessage(user.sub, messageId);
|
||||
}
|
||||
|
||||
@Post('blocks/:targetUserId')
|
||||
@Throttle(20, 60_000)
|
||||
async blockUser(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.chatService.blockUser(user.sub, targetUserId);
|
||||
}
|
||||
|
||||
@Patch('blocks/:targetUserId/unblock')
|
||||
@Throttle(20, 60_000)
|
||||
async unblockUser(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.chatService.unblockUser(user.sub, targetUserId);
|
||||
}
|
||||
|
||||
@Get('blocks/status/:targetUserId')
|
||||
async blockStatus(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.chatService.getBlockStatus(user.sub, targetUserId);
|
||||
}
|
||||
|
||||
@Get('blocks')
|
||||
async myBlocks(@CurrentUser() user: JwtPayload) {
|
||||
return this.chatService.getMyBlockedUsers(user.sub);
|
||||
}
|
||||
}
|
||||
137
src/modules/chat/chat.gateway.ts
Normal file
137
src/modules/chat/chat.gateway.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { ChatService } from './chat.service';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
|
||||
type SocketWithUser = Socket & { data: { userId?: string } };
|
||||
|
||||
@WebSocketGateway({ cors: { origin: '*' }, namespace: 'chat' })
|
||||
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
constructor(
|
||||
private readonly chatService: ChatService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: SocketWithUser) {
|
||||
const token = this.extractToken(client);
|
||||
if (!token) {
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string; tokenType: string }>(token, {
|
||||
secret: this.configService.get<string>('jwt.accessSecret', { infer: true }),
|
||||
});
|
||||
if (payload.tokenType !== 'access') {
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
client.data.userId = payload.sub;
|
||||
await client.join(this.userRoom(payload.sub));
|
||||
this.server.to(this.userRoom(payload.sub)).emit('presence', { userId: payload.sub, online: true });
|
||||
} catch {
|
||||
client.disconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: SocketWithUser) {
|
||||
const userId = client.data.userId;
|
||||
if (userId) {
|
||||
this.server.to(this.userRoom(userId)).emit('presence', { userId, online: false });
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('join_conversation')
|
||||
async joinConversation(
|
||||
@ConnectedSocket() client: SocketWithUser,
|
||||
@MessageBody() body: { conversationId: string },
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
if (!userId) return;
|
||||
|
||||
const conversation = await this.chatService.assertConversationMember(userId, body.conversationId);
|
||||
await client.join(this.conversationRoom(conversation.id));
|
||||
client.emit('joined_conversation', { conversationId: conversation.id });
|
||||
}
|
||||
|
||||
@SubscribeMessage('send_message')
|
||||
async sendMessage(
|
||||
@ConnectedSocket() client: SocketWithUser,
|
||||
@MessageBody() dto: SendMessageDto,
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
if (!userId) return;
|
||||
|
||||
const message = await this.chatService.sendMessage(userId, dto);
|
||||
this.server.to(this.conversationRoom(message.conversationId.toString())).emit('new_message', message);
|
||||
return message;
|
||||
}
|
||||
|
||||
@SubscribeMessage('typing')
|
||||
async typing(
|
||||
@ConnectedSocket() client: SocketWithUser,
|
||||
@MessageBody() body: { conversationId: string; isTyping: boolean },
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
if (!userId) return;
|
||||
|
||||
await this.chatService.assertConversationMember(userId, body.conversationId);
|
||||
client.to(this.conversationRoom(body.conversationId)).emit('typing', {
|
||||
conversationId: body.conversationId,
|
||||
userId,
|
||||
isTyping: !!body.isTyping,
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeMessage('mark_seen')
|
||||
async markSeen(
|
||||
@ConnectedSocket() client: SocketWithUser,
|
||||
@MessageBody() body: { messageId: string; conversationId: string },
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
if (!userId) return;
|
||||
|
||||
await this.chatService.markMessageSeen(userId, body.messageId);
|
||||
this.server.to(this.conversationRoom(body.conversationId)).emit('message_seen', {
|
||||
messageId: body.messageId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
private extractToken(client: Socket): string | null {
|
||||
const authToken = client.handshake.auth?.token;
|
||||
if (typeof authToken === 'string' && authToken.trim()) {
|
||||
return authToken.replace(/^Bearer\s+/i, '').trim();
|
||||
}
|
||||
|
||||
const headerAuth = client.handshake.headers.authorization;
|
||||
if (typeof headerAuth === 'string' && headerAuth.trim()) {
|
||||
return headerAuth.replace(/^Bearer\s+/i, '').trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private userRoom(userId: string): string {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
|
||||
private conversationRoom(conversationId: string): string {
|
||||
return `conversation:${conversationId}`;
|
||||
}
|
||||
}
|
||||
29
src/modules/chat/chat.module.ts
Normal file
29
src/modules/chat/chat.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatGateway } from './chat.gateway';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatRepository } from './chat.repository';
|
||||
import { ChatBlock, ChatBlockSchema } from './schemas/chat-block.schema';
|
||||
import { Conversation, ConversationSchema } from './schemas/conversation.schema';
|
||||
import { Message, MessageSchema } from './schemas/message.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
JwtModule.register({}),
|
||||
UsersModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: Conversation.name, schema: ConversationSchema },
|
||||
{ name: Message.name, schema: MessageSchema },
|
||||
{ name: ChatBlock.name, schema: ChatBlockSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatService, ChatRepository, ChatGateway],
|
||||
exports: [ChatService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
221
src/modules/chat/chat.repository.ts
Normal file
221
src/modules/chat/chat.repository.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types } from 'mongoose';
|
||||
import { ChatBlock, ChatBlockDocument } from './schemas/chat-block.schema';
|
||||
import { Conversation, ConversationDocument } from './schemas/conversation.schema';
|
||||
import { Message, MessageDocument } from './schemas/message.schema';
|
||||
|
||||
@Injectable()
|
||||
export class ChatRepository {
|
||||
constructor(
|
||||
@InjectModel(Conversation.name) private readonly conversationModel: Model<ConversationDocument>,
|
||||
@InjectModel(Message.name) private readonly messageModel: Model<MessageDocument>,
|
||||
@InjectModel(ChatBlock.name) private readonly chatBlockModel: Model<ChatBlockDocument>,
|
||||
) {}
|
||||
|
||||
async findConversationById(id: string): Promise<ConversationDocument | null> {
|
||||
return this.conversationModel.findById(id).exec();
|
||||
}
|
||||
|
||||
async findDirectConversation(userAId: string, userBId: string): Promise<ConversationDocument | null> {
|
||||
return this.conversationModel
|
||||
.findOne({
|
||||
isGroup: false,
|
||||
participantIds: {
|
||||
$all: [new Types.ObjectId(userAId), new Types.ObjectId(userBId)],
|
||||
$size: 2,
|
||||
},
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async createConversation(payload: {
|
||||
participantIds: string[];
|
||||
isGroup: boolean;
|
||||
title?: string;
|
||||
createdBy: string;
|
||||
}): Promise<ConversationDocument> {
|
||||
const participantIds = payload.participantIds.map((id) => new Types.ObjectId(id));
|
||||
const unreadCountByUser: Record<string, number> = {};
|
||||
payload.participantIds.forEach((id) => {
|
||||
unreadCountByUser[id] = 0;
|
||||
});
|
||||
|
||||
return this.conversationModel.create({
|
||||
participantIds,
|
||||
isGroup: payload.isGroup,
|
||||
title: payload.title ?? '',
|
||||
createdBy: new Types.ObjectId(payload.createdBy),
|
||||
unreadCountByUser,
|
||||
lastMessageText: '',
|
||||
});
|
||||
}
|
||||
|
||||
async findConversationsForUser(userId: string, skip: number, limit: number): Promise<ConversationDocument[]> {
|
||||
return this.conversationModel
|
||||
.find({ participantIds: new Types.ObjectId(userId) })
|
||||
.populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ lastMessageAt: -1, updatedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countConversationsForUser(userId: string): Promise<number> {
|
||||
return this.conversationModel.countDocuments({ participantIds: new Types.ObjectId(userId) }).exec();
|
||||
}
|
||||
|
||||
async createMessage(payload: {
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
content?: string;
|
||||
messageType: 'text' | 'image' | 'video' | 'audio';
|
||||
mediaUrl?: string;
|
||||
}): Promise<MessageDocument> {
|
||||
return this.messageModel.create({
|
||||
conversationId: new Types.ObjectId(payload.conversationId),
|
||||
senderId: new Types.ObjectId(payload.senderId),
|
||||
content: payload.content ?? '',
|
||||
messageType: payload.messageType,
|
||||
mediaUrl: payload.mediaUrl ?? '',
|
||||
seenBy: [new Types.ObjectId(payload.senderId)],
|
||||
isUnsent: false,
|
||||
});
|
||||
}
|
||||
|
||||
async findMessages(conversationId: string, skip: number, limit: number): Promise<MessageDocument[]> {
|
||||
return this.messageModel
|
||||
.find({ conversationId: new Types.ObjectId(conversationId) })
|
||||
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countMessages(conversationId: string): Promise<number> {
|
||||
return this.messageModel.countDocuments({ conversationId: new Types.ObjectId(conversationId) }).exec();
|
||||
}
|
||||
|
||||
async findMessageById(messageId: string): Promise<MessageDocument | null> {
|
||||
return this.messageModel.findById(messageId).exec();
|
||||
}
|
||||
|
||||
async markMessageSeen(messageId: string, userId: string): Promise<void> {
|
||||
await this.messageModel
|
||||
.findByIdAndUpdate(messageId, { $addToSet: { seenBy: new Types.ObjectId(userId) } }, { new: false })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateConversationAfterNewMessage(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
senderId: string,
|
||||
messageText: string,
|
||||
): Promise<ConversationDocument | null> {
|
||||
const conversation = await this.conversationModel.findById(conversationId).exec();
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unreadMap = new Map<string, number>(
|
||||
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
|
||||
);
|
||||
|
||||
for (const participantId of conversation.participantIds) {
|
||||
const id = participantId.toString();
|
||||
if (id === senderId) {
|
||||
unreadMap.set(id, 0);
|
||||
} else {
|
||||
unreadMap.set(id, (unreadMap.get(id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
conversation.lastMessageId = new Types.ObjectId(messageId);
|
||||
conversation.lastMessageText = messageText.slice(0, 4000);
|
||||
conversation.lastMessageAt = new Date();
|
||||
conversation.unreadCountByUser = unreadMap as unknown as Map<string, number>;
|
||||
await conversation.save();
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async clearConversationUnreadForUser(conversationId: string, userId: string): Promise<void> {
|
||||
const conversation = await this.conversationModel.findById(conversationId).exec();
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unreadMap = new Map<string, number>(
|
||||
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
|
||||
);
|
||||
unreadMap.set(userId, 0);
|
||||
conversation.unreadCountByUser = unreadMap as unknown as Map<string, number>;
|
||||
await conversation.save();
|
||||
}
|
||||
|
||||
async unsendMessage(messageId: string, senderId: string): Promise<MessageDocument | null> {
|
||||
return this.messageModel
|
||||
.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(messageId), senderId: new Types.ObjectId(senderId) },
|
||||
{
|
||||
isUnsent: true,
|
||||
content: '',
|
||||
mediaUrl: '',
|
||||
messageType: 'text',
|
||||
},
|
||||
{ new: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findManyMessages(filter: FilterQuery<MessageDocument>): Promise<MessageDocument[]> {
|
||||
return this.messageModel.find(filter).exec();
|
||||
}
|
||||
|
||||
async createBlock(blockerId: string, blockedId: string): Promise<void> {
|
||||
await this.chatBlockModel
|
||||
.updateOne(
|
||||
{ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) },
|
||||
{
|
||||
$setOnInsert: {
|
||||
blockerId: new Types.ObjectId(blockerId),
|
||||
blockedId: new Types.ObjectId(blockedId),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async removeBlock(blockerId: string, blockedId: string): Promise<void> {
|
||||
await this.chatBlockModel
|
||||
.deleteOne({ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findBlock(blockerId: string, blockedId: string): Promise<ChatBlockDocument | null> {
|
||||
return this.chatBlockModel
|
||||
.findOne({ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findAnyBlockBetween(userAId: string, userBId: string): Promise<ChatBlockDocument | null> {
|
||||
return this.chatBlockModel
|
||||
.findOne({
|
||||
$or: [
|
||||
{ blockerId: new Types.ObjectId(userAId), blockedId: new Types.ObjectId(userBId) },
|
||||
{ blockerId: new Types.ObjectId(userBId), blockedId: new Types.ObjectId(userAId) },
|
||||
],
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findBlocksByBlocker(blockerId: string): Promise<ChatBlockDocument[]> {
|
||||
return this.chatBlockModel
|
||||
.find({ blockerId: new Types.ObjectId(blockerId) })
|
||||
.populate({ path: 'blockedId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
250
src/modules/chat/chat.service.ts
Normal file
250
src/modules/chat/chat.service.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { CreateConversationDto } from './dto/create-conversation.dto';
|
||||
import { MessageQueryDto } from './dto/message-query.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
import { ChatRepository } from './chat.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly chatRepository: ChatRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
) {}
|
||||
|
||||
async createConversation(currentUserId: string, dto: CreateConversationDto) {
|
||||
const uniqueParticipantIds = Array.from(new Set([currentUserId, ...dto.participantIds]));
|
||||
if (uniqueParticipantIds.length < 2) {
|
||||
throw new BadRequestException('Conversation must include at least 2 participants');
|
||||
}
|
||||
|
||||
for (const participantId of uniqueParticipantIds) {
|
||||
if (!Types.ObjectId.isValid(participantId)) {
|
||||
throw new BadRequestException('Invalid participant id');
|
||||
}
|
||||
}
|
||||
|
||||
const users = await Promise.all(uniqueParticipantIds.map((id) => this.usersRepository.findById(id)));
|
||||
if (users.some((u) => !u || u.isDisabled)) {
|
||||
throw new BadRequestException('One or more participants are invalid or disabled');
|
||||
}
|
||||
|
||||
const isGroup = dto.isGroup ?? uniqueParticipantIds.length > 2;
|
||||
if (!isGroup && uniqueParticipantIds.length !== 2) {
|
||||
throw new BadRequestException('Direct conversation must contain exactly 2 participants');
|
||||
}
|
||||
|
||||
if (!isGroup) {
|
||||
const otherId = uniqueParticipantIds.find((id) => id !== currentUserId) as string;
|
||||
const block = await this.chatRepository.findAnyBlockBetween(currentUserId, otherId);
|
||||
if (block) {
|
||||
throw new ForbiddenException('You cannot start chat with this user');
|
||||
}
|
||||
const existing = await this.chatRepository.findDirectConversation(currentUserId, otherId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
return this.chatRepository.createConversation({
|
||||
participantIds: uniqueParticipantIds,
|
||||
isGroup,
|
||||
title: dto.title,
|
||||
createdBy: currentUserId,
|
||||
});
|
||||
}
|
||||
|
||||
async getMyConversations(currentUserId: string, query: MessageQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.chatRepository.findConversationsForUser(currentUserId, skip, limit),
|
||||
this.chatRepository.countConversationsForUser(currentUserId),
|
||||
]);
|
||||
|
||||
const mappedItems = items.map((conversation) => {
|
||||
const unreadMap = (conversation.unreadCountByUser as unknown as Record<string, number>) ?? {};
|
||||
return {
|
||||
...conversation.toObject(),
|
||||
unreadCount: unreadMap[currentUserId] ?? 0,
|
||||
lastMessageAt: conversation.lastMessageAt ?? null,
|
||||
};
|
||||
});
|
||||
const nextOffset = skip + mappedItems.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items: mappedItems,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
|
||||
const conversation = await this.assertConversationMember(currentUserId, conversationId);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.chatRepository.findMessages(conversation.id, skip, limit),
|
||||
this.chatRepository.countMessages(conversation.id),
|
||||
]);
|
||||
|
||||
await this.chatRepository.clearConversationUnreadForUser(conversation.id, currentUserId);
|
||||
const nextOffset = skip + items.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async sendMessage(currentUserId: string, dto: SendMessageDto) {
|
||||
const conversation = await this.assertConversationMember(currentUserId, dto.conversationId);
|
||||
await this.assertNoChatBlockInConversation(currentUserId, conversation.participantIds.map((id) => id.toString()));
|
||||
const messageType = dto.messageType ?? 'text';
|
||||
const content = dto.content?.trim() ?? '';
|
||||
const mediaUrl = dto.mediaUrl?.trim() ?? '';
|
||||
|
||||
if (messageType === 'text' && !content) {
|
||||
throw new BadRequestException('Text message content is required');
|
||||
}
|
||||
|
||||
if (messageType !== 'text' && !mediaUrl) {
|
||||
throw new BadRequestException('mediaUrl is required for non-text messages');
|
||||
}
|
||||
|
||||
const message = await this.chatRepository.createMessage({
|
||||
conversationId: conversation.id,
|
||||
senderId: currentUserId,
|
||||
content,
|
||||
messageType,
|
||||
mediaUrl,
|
||||
});
|
||||
|
||||
const preview = messageType === 'text' ? content : `${messageType} message`;
|
||||
await this.chatRepository.updateConversationAfterNewMessage(
|
||||
conversation.id,
|
||||
message.id,
|
||||
currentUserId,
|
||||
preview,
|
||||
);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async markMessageSeen(currentUserId: string, messageId: string) {
|
||||
const message = await this.chatRepository.findMessageById(messageId);
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
|
||||
await this.assertConversationMember(currentUserId, message.conversationId.toString());
|
||||
await this.chatRepository.markMessageSeen(message.id, currentUserId);
|
||||
await this.chatRepository.clearConversationUnreadForUser(message.conversationId.toString(), currentUserId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async unsendMessage(currentUserId: string, messageId: string) {
|
||||
const message = await this.chatRepository.findMessageById(messageId);
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
if (message.senderId.toString() !== currentUserId) {
|
||||
throw new ForbiddenException('You can only unsend your own messages');
|
||||
}
|
||||
|
||||
const updated = await this.chatRepository.unsendMessage(messageId, currentUserId);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async blockUser(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new BadRequestException('You cannot block yourself');
|
||||
}
|
||||
|
||||
const target = await this.usersRepository.findById(targetUserId);
|
||||
if (!target) {
|
||||
throw new NotFoundException('Target user not found');
|
||||
}
|
||||
|
||||
await this.chatRepository.createBlock(currentUserId, targetUserId);
|
||||
return { blocked: true, targetUserId };
|
||||
}
|
||||
|
||||
async unblockUser(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
await this.chatRepository.removeBlock(currentUserId, targetUserId);
|
||||
return { blocked: false, targetUserId };
|
||||
}
|
||||
|
||||
async getBlockStatus(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
|
||||
const iBlocked = !!(await this.chatRepository.findBlock(currentUserId, targetUserId));
|
||||
const blockedMe = !!(await this.chatRepository.findBlock(targetUserId, currentUserId));
|
||||
|
||||
return { targetUserId, iBlocked, blockedMe };
|
||||
}
|
||||
|
||||
async getMyBlockedUsers(currentUserId: string) {
|
||||
const items = await this.chatRepository.findBlocksByBlocker(currentUserId);
|
||||
return { items };
|
||||
}
|
||||
|
||||
async assertConversationMember(userId: string, conversationId: string) {
|
||||
if (!Types.ObjectId.isValid(conversationId)) {
|
||||
throw new BadRequestException('Invalid conversation id');
|
||||
}
|
||||
|
||||
const conversation = await this.chatRepository.findConversationById(conversationId);
|
||||
if (!conversation) {
|
||||
throw new NotFoundException('Conversation not found');
|
||||
}
|
||||
|
||||
const isMember = conversation.participantIds.some((id) => id.toString() === userId);
|
||||
if (!isMember) {
|
||||
throw new ForbiddenException('You are not a member of this conversation');
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private async assertNoChatBlockInConversation(currentUserId: string, participantIds: string[]) {
|
||||
for (const participantId of participantIds) {
|
||||
if (participantId === currentUserId) {
|
||||
continue;
|
||||
}
|
||||
const block = await this.chatRepository.findAnyBlockBetween(currentUserId, participantId);
|
||||
if (block) {
|
||||
throw new ForbiddenException('Cannot send message because one of participants is blocked');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/modules/chat/dto/create-conversation.dto.ts
Normal file
19
src/modules/chat/dto/create-conversation.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class CreateConversationDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
participantIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isGroup?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 120 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 120)
|
||||
title?: string;
|
||||
}
|
||||
3
src/modules/chat/dto/message-query.dto.ts
Normal file
3
src/modules/chat/dto/message-query.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export class MessageQueryDto extends PaginationQueryDto {}
|
||||
23
src/modules/chat/dto/send-message.dto.ts
Normal file
23
src/modules/chat/dto/send-message.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
|
||||
|
||||
export class SendMessageDto {
|
||||
@IsString()
|
||||
conversationId!: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 4000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 4000)
|
||||
content?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['text', 'image', 'video', 'audio'], default: 'text' })
|
||||
@IsOptional()
|
||||
@IsEnum(['text', 'image', 'video', 'audio'])
|
||||
messageType?: 'text' | 'image' | 'video' | 'audio';
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
mediaUrl?: string;
|
||||
}
|
||||
17
src/modules/chat/schemas/chat-block.schema.ts
Normal file
17
src/modules/chat/schemas/chat-block.schema.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type ChatBlockDocument = HydratedDocument<ChatBlock>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class ChatBlock {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
blockerId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
blockedId!: Types.ObjectId;
|
||||
}
|
||||
|
||||
export const ChatBlockSchema = SchemaFactory.createForClass(ChatBlock);
|
||||
ChatBlockSchema.index({ blockerId: 1, blockedId: 1 }, { unique: true });
|
||||
37
src/modules/chat/schemas/conversation.schema.ts
Normal file
37
src/modules/chat/schemas/conversation.schema.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type ConversationDocument = HydratedDocument<Conversation>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Conversation {
|
||||
@Prop({ type: [Types.ObjectId], ref: User.name, required: true, index: true })
|
||||
participantIds!: Types.ObjectId[];
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isGroup!: boolean;
|
||||
|
||||
@Prop({ default: '', maxlength: 120, trim: true })
|
||||
title!: string;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: false, index: true })
|
||||
createdBy?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, required: false, index: true })
|
||||
lastMessageId?: Types.ObjectId;
|
||||
|
||||
@Prop({ default: '', maxlength: 4000 })
|
||||
lastMessageText!: string;
|
||||
|
||||
@Prop({ type: Date, required: false, index: true })
|
||||
lastMessageAt?: Date;
|
||||
|
||||
@Prop({ type: Map, of: Number, default: {} })
|
||||
unreadCountByUser!: Map<string, number>;
|
||||
}
|
||||
|
||||
export const ConversationSchema = SchemaFactory.createForClass(Conversation);
|
||||
ConversationSchema.index({ participantIds: 1, updatedAt: -1 });
|
||||
ConversationSchema.index({ lastMessageAt: -1, updatedAt: -1 });
|
||||
ConversationSchema.index({ participantIds: 1, isGroup: 1, lastMessageAt: -1 });
|
||||
33
src/modules/chat/schemas/message.schema.ts
Normal file
33
src/modules/chat/schemas/message.schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type MessageDocument = HydratedDocument<Message>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Message {
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
conversationId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
senderId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: false, default: '', maxlength: 4000 })
|
||||
content!: string;
|
||||
|
||||
@Prop({ enum: ['text', 'image', 'video', 'audio'], default: 'text', index: true })
|
||||
messageType!: 'text' | 'image' | 'video' | 'audio';
|
||||
|
||||
@Prop({ required: false, default: '' })
|
||||
mediaUrl!: string;
|
||||
|
||||
@Prop({ type: [Types.ObjectId], ref: User.name, default: [] })
|
||||
seenBy!: Types.ObjectId[];
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isUnsent!: boolean;
|
||||
}
|
||||
|
||||
export const MessageSchema = SchemaFactory.createForClass(Message);
|
||||
MessageSchema.index({ conversationId: 1, createdAt: -1 });
|
||||
MessageSchema.index({ conversationId: 1, isUnsent: 1, createdAt: -1 });
|
||||
50
src/modules/comments/comments.controller.ts
Normal file
50
src/modules/comments/comments.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { CommentQueryDto } from './dto/comment-query.dto';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CommentsService } from './comments.service';
|
||||
|
||||
@ApiTags('Comments')
|
||||
@Controller('comments')
|
||||
export class CommentsController {
|
||||
constructor(private readonly commentsService: CommentsService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreateCommentDto) {
|
||||
return this.commentsService.create(user.sub, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('post/:postId')
|
||||
async findByPost(@Param('postId') postId: string, @Query() query: CommentQueryDto) {
|
||||
return this.commentsService.findByPost(postId, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get(':commentId/replies')
|
||||
async findReplies(@Param('commentId') commentId: string, @Query() query: CommentQueryDto) {
|
||||
return this.commentsService.findReplies(commentId, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':commentId')
|
||||
async remove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
||||
return this.commentsService.remove(user.sub, commentId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@Delete('admin/:commentId')
|
||||
async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
||||
return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId);
|
||||
}
|
||||
}
|
||||
20
src/modules/comments/comments.module.ts
Normal file
20
src/modules/comments/comments.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { PostsModule } from '../posts/posts.module';
|
||||
import { Comment, CommentSchema } from './schemas/comment.schema';
|
||||
import { CommentsController } from './comments.controller';
|
||||
import { CommentsService } from './comments.service';
|
||||
import { CommentsRepository } from './comments.repository';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuditModule,
|
||||
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
|
||||
PostsModule,
|
||||
],
|
||||
controllers: [CommentsController],
|
||||
providers: [CommentsService, CommentsRepository],
|
||||
exports: [CommentsService, CommentsRepository],
|
||||
})
|
||||
export class CommentsModule {}
|
||||
77
src/modules/comments/comments.repository.ts
Normal file
77
src/modules/comments/comments.repository.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
|
||||
import { Comment, CommentDocument } from './schemas/comment.schema';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsRepository {
|
||||
constructor(@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>) {}
|
||||
|
||||
private withActiveFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
|
||||
return {
|
||||
...filter,
|
||||
isDeleted: { $ne: true },
|
||||
};
|
||||
}
|
||||
|
||||
async create(
|
||||
payload: { postId: string; authorId: string; content: string; parentCommentId?: string },
|
||||
session?: ClientSession,
|
||||
) {
|
||||
return this.commentModel.create({
|
||||
postId: new Types.ObjectId(payload.postId),
|
||||
authorId: new Types.ObjectId(payload.authorId),
|
||||
content: payload.content,
|
||||
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
|
||||
}, { session });
|
||||
}
|
||||
|
||||
async findById(commentId: string): Promise<CommentDocument | null> {
|
||||
if (!Types.ObjectId.isValid(commentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.commentModel
|
||||
.findOne({ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async deleteById(commentId: string, deletedBy?: string, session?: ClientSession): Promise<boolean> {
|
||||
if (!Types.ObjectId.isValid(commentId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deletedByObjectId =
|
||||
deletedBy && Types.ObjectId.isValid(deletedBy) ? new Types.ObjectId(deletedBy) : null;
|
||||
|
||||
const updated = await this.commentModel
|
||||
.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } },
|
||||
{ isDeleted: true, deletedAt: new Date(), deletedBy: deletedByObjectId },
|
||||
{ new: false, session },
|
||||
)
|
||||
.exec();
|
||||
|
||||
return !!updated;
|
||||
}
|
||||
|
||||
async findMany(filter: FilterQuery<CommentDocument>, skip: number, limit: number) {
|
||||
return this.commentModel
|
||||
.find(this.withActiveFilter(filter))
|
||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async count(filter: FilterQuery<CommentDocument>): Promise<number> {
|
||||
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
|
||||
}
|
||||
|
||||
async countByPost(postId: string): Promise<number> {
|
||||
return this.commentModel
|
||||
.countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
114
src/modules/comments/comments.service.ts
Normal file
114
src/modules/comments/comments.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PostsRepository } from '../posts/posts.repository';
|
||||
import { CommentQueryDto } from './dto/comment-query.dto';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CommentsRepository } from './comments.repository';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
constructor(
|
||||
private readonly commentsRepository: CommentsRepository,
|
||||
private readonly postsRepository: PostsRepository,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateCommentDto) {
|
||||
const post = await this.postsRepository.findById(dto.postId);
|
||||
if (!post) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
|
||||
if (dto.parentCommentId) {
|
||||
const parent = await this.commentsRepository.findById(dto.parentCommentId);
|
||||
if (!parent || parent.postId.toString() !== dto.postId) {
|
||||
throw new NotFoundException('Parent comment not found');
|
||||
}
|
||||
}
|
||||
|
||||
const comment = await this.commentsRepository.create({
|
||||
postId: dto.postId,
|
||||
authorId: userId,
|
||||
content: dto.content,
|
||||
parentCommentId: dto.parentCommentId,
|
||||
});
|
||||
await this.syncCommentsCount(dto.postId);
|
||||
return comment;
|
||||
}
|
||||
|
||||
async remove(userId: string, commentId: string) {
|
||||
const comment = await this.commentsRepository.findById(commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
if (comment.authorId.toString() !== userId) {
|
||||
throw new ForbiddenException('You can only delete your own comments');
|
||||
}
|
||||
|
||||
await this.commentsRepository.deleteById(commentId, userId);
|
||||
await this.syncCommentsCount(comment.postId.toString());
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeBySuperAdmin(superAdminIdentifier: string, commentId: string) {
|
||||
const comment = await this.commentsRepository.findById(commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
await this.commentsRepository.deleteById(commentId, superAdminIdentifier);
|
||||
await this.syncCommentsCount(comment.postId.toString());
|
||||
await this.auditService.logSuperAdminAction(
|
||||
superAdminIdentifier,
|
||||
'comment_delete',
|
||||
'comment',
|
||||
commentId,
|
||||
{ postId: comment.postId.toString() },
|
||||
);
|
||||
return { success: true, message: 'Comment deleted by superadmin' };
|
||||
}
|
||||
|
||||
async findByPost(postId: string, query: CommentQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.commentsRepository.findMany({ postId, parentCommentId: { $exists: false } }, skip, limit),
|
||||
this.commentsRepository.count({ postId, parentCommentId: { $exists: false } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async findReplies(parentCommentId: string, query: CommentQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.commentsRepository.findMany({ parentCommentId }, skip, limit),
|
||||
this.commentsRepository.count({ parentCommentId }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
private async syncCommentsCount(postId: string): Promise<void> {
|
||||
const totalComments = await this.commentsRepository.countByPost(postId);
|
||||
await this.postsRepository.setCommentsCount(postId, totalComments);
|
||||
}
|
||||
}
|
||||
3
src/modules/comments/dto/comment-query.dto.ts
Normal file
3
src/modules/comments/dto/comment-query.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export class CommentQueryDto extends PaginationQueryDto {}
|
||||
18
src/modules/comments/dto/create-comment.dto.ts
Normal file
18
src/modules/comments/dto/create-comment.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsMongoId, IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@ApiProperty()
|
||||
@IsMongoId()
|
||||
postId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Length(1, 1000)
|
||||
content!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
parentCommentId?: string;
|
||||
}
|
||||
8
src/modules/comments/dto/update-comment.dto.ts
Normal file
8
src/modules/comments/dto/update-comment.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class UpdateCommentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 1000)
|
||||
content?: string;
|
||||
}
|
||||
34
src/modules/comments/schemas/comment.schema.ts
Normal file
34
src/modules/comments/schemas/comment.schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { Post } from '../../posts/schemas/post.schema';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type CommentDocument = HydratedDocument<Comment>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Comment {
|
||||
@Prop({ type: Types.ObjectId, ref: Post.name, required: true, index: true })
|
||||
postId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
authorId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, required: false, index: true })
|
||||
parentCommentId?: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true, maxlength: 1000 })
|
||||
content!: string;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isDeleted!: boolean;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
deletedAt?: Date | null;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, default: null })
|
||||
deletedBy?: Types.ObjectId | null;
|
||||
}
|
||||
|
||||
export const CommentSchema = SchemaFactory.createForClass(Comment);
|
||||
CommentSchema.index({ postId: 1, createdAt: -1 });
|
||||
CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 });
|
||||
8
src/modules/email/email.module.ts
Normal file
8
src/modules/email/email.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmailService } from './email.service';
|
||||
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
134
src/modules/email/email.service.ts
Normal file
134
src/modules/email/email.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private readonly logger = new Logger(EmailService.name);
|
||||
private transporter: nodemailer.Transporter<SMTPTransport.SentMessageInfo> | null = null;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async sendVerificationCode(email: string, code: string, expiresMinutes: number): Promise<void> {
|
||||
const subject = 'تأكيد البريد الإلكتروني - Oudelaa';
|
||||
const text = [
|
||||
'مرحباً،',
|
||||
`رمز تأكيد الحساب الخاص بك هو: ${code}`,
|
||||
`صلاحية الرمز: ${expiresMinutes} دقيقة.`,
|
||||
'إذا لم تطلب هذا الرمز، تجاهل هذه الرسالة.',
|
||||
].join('\n');
|
||||
const html = this.buildCodeEmailHtml({
|
||||
title: 'تأكيد البريد الإلكتروني',
|
||||
intro: 'استخدم الرمز التالي لإكمال تفعيل حسابك في Oudelaa:',
|
||||
code,
|
||||
expiresMinutes,
|
||||
footerNote: 'إذا لم تطلب هذا الرمز، يمكنك تجاهل الرسالة بأمان.',
|
||||
});
|
||||
await this.send(email, subject, text, html);
|
||||
}
|
||||
|
||||
async sendPasswordResetCode(email: string, code: string, expiresMinutes: number): Promise<void> {
|
||||
const subject = 'إعادة تعيين كلمة المرور - Oudelaa';
|
||||
const text = [
|
||||
'مرحباً،',
|
||||
`رمز إعادة تعيين كلمة المرور هو: ${code}`,
|
||||
`صلاحية الرمز: ${expiresMinutes} دقيقة.`,
|
||||
'إذا لم تطلب إعادة تعيين كلمة المرور، ننصحك بتغيير كلمة المرور مباشرة.',
|
||||
].join('\n');
|
||||
const html = this.buildCodeEmailHtml({
|
||||
title: 'إعادة تعيين كلمة المرور',
|
||||
intro: 'استخدم الرمز التالي لإعادة تعيين كلمة المرور:',
|
||||
code,
|
||||
expiresMinutes,
|
||||
footerNote: 'إذا لم تطلب إعادة التعيين، تجاهل هذه الرسالة وقم بمراجعة أمان حسابك.',
|
||||
});
|
||||
await this.send(email, subject, text, html);
|
||||
}
|
||||
|
||||
private buildCodeEmailHtml(params: {
|
||||
title: string;
|
||||
intro: string;
|
||||
code: string;
|
||||
expiresMinutes: number;
|
||||
footerNote: string;
|
||||
}): string {
|
||||
return `
|
||||
<div style="font-family: Arial, sans-serif; background:#f6f8fb; padding:24px; direction:rtl; text-align:right;">
|
||||
<div style="max-width:520px; margin:0 auto; background:#ffffff; border-radius:12px; padding:24px; border:1px solid #e8edf3;">
|
||||
<h2 style="margin:0 0 12px; color:#111827;">${params.title}</h2>
|
||||
<p style="margin:0 0 16px; color:#374151; line-height:1.7;">${params.intro}</p>
|
||||
<div style="font-size:32px; letter-spacing:6px; font-weight:700; color:#0f172a; background:#f1f5f9; border-radius:10px; text-align:center; padding:14px 8px; margin:0 0 14px;">
|
||||
${params.code}
|
||||
</div>
|
||||
<p style="margin:0 0 10px; color:#475569;">صلاحية الرمز: <strong>${params.expiresMinutes} دقيقة</strong></p>
|
||||
<p style="margin:0; color:#64748b; font-size:13px;">${params.footerNote}</p>
|
||||
<hr style="border:none; border-top:1px solid #e5e7eb; margin:18px 0;" />
|
||||
<p style="margin:0; color:#94a3b8; font-size:12px;">Oudelaa Team</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async send(to: string, subject: string, text: string, html: string): Promise<void> {
|
||||
const enabled = this.configService.get<boolean>('email.enabled', { infer: true });
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromName = this.configService.get<string>('email.fromName', { infer: true }) ?? 'Oudelaa';
|
||||
const fromEmail = this.configService.get<string>('email.fromEmail', { infer: true }) ?? '';
|
||||
if (!fromEmail) {
|
||||
throw new ServiceUnavailableException('Email sender is not configured');
|
||||
}
|
||||
|
||||
const transporter = this.getTransporter();
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: `${fromName} <${fromEmail}>`,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send email to ${to}`, error as Error);
|
||||
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||
if (nodeEnv === 'development') {
|
||||
const err = error as Error & { code?: string; message?: string };
|
||||
throw new ServiceUnavailableException(
|
||||
`Failed to send verification email (${err.code ?? 'SMTP_ERROR'}: ${err.message ?? 'unknown'})`,
|
||||
);
|
||||
}
|
||||
throw new ServiceUnavailableException('Failed to send verification email');
|
||||
}
|
||||
}
|
||||
|
||||
private getTransporter(): nodemailer.Transporter<SMTPTransport.SentMessageInfo> {
|
||||
if (this.transporter) {
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
const host = this.configService.get<string>('email.smtpHost', { infer: true }) ?? '';
|
||||
const port = this.configService.get<number>('email.smtpPort', { infer: true }) ?? 587;
|
||||
const secure = this.configService.get<boolean>('email.smtpSecure', { infer: true }) ?? false;
|
||||
const user = this.configService.get<string>('email.smtpUser', { infer: true }) ?? '';
|
||||
const pass = this.configService.get<string>('email.smtpPass', { infer: true }) ?? '';
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
throw new ServiceUnavailableException('SMTP settings are not configured');
|
||||
}
|
||||
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
auth: {
|
||||
user,
|
||||
pass,
|
||||
},
|
||||
});
|
||||
|
||||
return this.transporter;
|
||||
}
|
||||
}
|
||||
22
src/modules/feed/dto/feed-query.dto.ts
Normal file
22
src/modules/feed/dto/feed-query.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import { IsBoolean, IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { PostType } from '../../../common/enums/post-type.enum';
|
||||
|
||||
export class FeedQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsEnum(PostType)
|
||||
preferredPostType?: PostType;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
followingOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
radiusKm?: number;
|
||||
}
|
||||
27
src/modules/feed/feed.controller.ts
Normal file
27
src/modules/feed/feed.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { FeedQueryDto } from './dto/feed-query.dto';
|
||||
import { FeedService } from './feed.service';
|
||||
|
||||
@ApiTags('Feed')
|
||||
@Controller('feed')
|
||||
export class FeedController {
|
||||
constructor(private readonly feedService: FeedService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
async myFeed(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
|
||||
return this.feedService.getMyFeed(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('trending')
|
||||
async trending(@Query() query: FeedQueryDto) {
|
||||
return this.feedService.getTrending(query);
|
||||
}
|
||||
}
|
||||
22
src/modules/feed/feed.module.ts
Normal file
22
src/modules/feed/feed.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Follow, FollowSchema } from '../follows/schemas/follow.schema';
|
||||
import { Post, PostSchema } from '../posts/schemas/post.schema';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { FeedController } from './feed.controller';
|
||||
import { FeedService } from './feed.service';
|
||||
import { FeedRepository } from './feed.repository';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: Post.name, schema: PostSchema },
|
||||
{ name: Follow.name, schema: FollowSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [FeedController],
|
||||
providers: [FeedService, FeedRepository],
|
||||
exports: [FeedService],
|
||||
})
|
||||
export class FeedModule {}
|
||||
58
src/modules/feed/feed.repository.ts
Normal file
58
src/modules/feed/feed.repository.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types } from 'mongoose';
|
||||
import { Follow, FollowDocument } from '../follows/schemas/follow.schema';
|
||||
import { Post, PostDocument } from '../posts/schemas/post.schema';
|
||||
|
||||
@Injectable()
|
||||
export class FeedRepository {
|
||||
constructor(
|
||||
@InjectModel(Post.name) private readonly postModel: Model<PostDocument>,
|
||||
@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>,
|
||||
) {}
|
||||
|
||||
async findFollowingIds(userId: string): Promise<string[]> {
|
||||
const rows = await this.followModel
|
||||
.find({ followerId: new Types.ObjectId(userId) })
|
||||
.select({ followingId: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return rows.map((row) => row.followingId.toString());
|
||||
}
|
||||
|
||||
async findCandidatePosts(
|
||||
filter: FilterQuery<PostDocument>,
|
||||
limit: number,
|
||||
): Promise<PostDocument[]> {
|
||||
const activeFilter: FilterQuery<PostDocument> = {
|
||||
...filter,
|
||||
isDeleted: { $ne: true },
|
||||
};
|
||||
|
||||
return this.postModel
|
||||
.find(activeFilter)
|
||||
.populate({
|
||||
path: 'authorId',
|
||||
select:
|
||||
'name username stageName avatar isVerified isDisabled location latitude longitude musicGenres musicRoles favoriteInstruments favoriteMaqamat',
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findTrendingPublicPosts(skip: number, limit: number): Promise<PostDocument[]> {
|
||||
return this.postModel
|
||||
.find({ visibility: 'public', isDeleted: { $ne: true } })
|
||||
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ likesCount: -1, commentsCount: -1, savesCount: -1, createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async count(filter: FilterQuery<PostDocument>): Promise<number> {
|
||||
return this.postModel.countDocuments({ ...filter, isDeleted: { $ne: true } }).exec();
|
||||
}
|
||||
}
|
||||
212
src/modules/feed/feed.service.ts
Normal file
212
src/modules/feed/feed.service.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||
import { PostType } from '../../common/enums/post-type.enum';
|
||||
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { UserDocument } from '../users/schemas/user.schema';
|
||||
import { FeedQueryDto } from './dto/feed-query.dto';
|
||||
import { FeedRepository } from './feed.repository';
|
||||
|
||||
@Injectable()
|
||||
export class FeedService {
|
||||
constructor(
|
||||
private readonly feedRepository: FeedRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
) {}
|
||||
|
||||
async getMyFeed(currentUserId: string, query: FeedQueryDto) {
|
||||
const currentUser = await this.usersRepository.findById(currentUserId);
|
||||
if (!currentUser) {
|
||||
throw new NotFoundException('Current user not found');
|
||||
}
|
||||
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const page = query.page ?? 1;
|
||||
const followingOnly = query.followingOnly ?? false;
|
||||
const radiusKm = query.radiusKm ?? 30;
|
||||
|
||||
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
|
||||
const visibleAuthorIds = followingOnly ? [currentUserId, ...followingIds] : null;
|
||||
|
||||
const filter: Record<string, unknown> = {
|
||||
$or: [
|
||||
{ visibility: PostVisibility.PUBLIC },
|
||||
{ authorId: new Types.ObjectId(currentUserId) },
|
||||
],
|
||||
};
|
||||
|
||||
if (visibleAuthorIds) {
|
||||
filter.authorId = { $in: visibleAuthorIds.map((id) => new Types.ObjectId(id)) };
|
||||
}
|
||||
|
||||
const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||
|
||||
const scored = candidates
|
||||
.filter((post) => {
|
||||
if (!query.preferredPostType) {
|
||||
return true;
|
||||
}
|
||||
return post.postType === query.preferredPostType;
|
||||
})
|
||||
.map((post) => ({
|
||||
post,
|
||||
score: this.scorePost({
|
||||
currentUser,
|
||||
currentUserId,
|
||||
followingIds,
|
||||
post,
|
||||
preferredPostType: query.preferredPostType,
|
||||
radiusKm,
|
||||
}),
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
new Date((b.post as any).createdAt ?? 0).getTime() - new Date((a.post as any).createdAt ?? 0).getTime(),
|
||||
);
|
||||
|
||||
const total = scored.length;
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
const items = scored.slice(skip, skip + limit).map((entry) => ({
|
||||
...entry.post.toObject(),
|
||||
feedScore: Number(entry.score.toFixed(3)),
|
||||
}));
|
||||
const nextOffset = skip + items.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async getTrending(query: FeedQueryDto) {
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const page = query.page ?? 1;
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.feedRepository.findTrendingPublicPosts(skip, limit),
|
||||
this.feedRepository.count({ visibility: PostVisibility.PUBLIC }),
|
||||
]);
|
||||
const nextOffset = skip + items.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
private scorePost(input: {
|
||||
currentUser: UserDocument;
|
||||
currentUserId: string;
|
||||
followingIds: string[];
|
||||
post: any;
|
||||
preferredPostType?: PostType;
|
||||
radiusKm: number;
|
||||
}): number {
|
||||
const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input;
|
||||
const author: any = post.authorId;
|
||||
const authorId = typeof author === 'string' ? author : author?._id?.toString?.() ?? '';
|
||||
const isOwnPost = authorId === currentUserId;
|
||||
const isFollowing = followingIds.includes(authorId);
|
||||
|
||||
const ageMs = Date.now() - new Date(post.createdAt).getTime();
|
||||
const ageHours = ageMs / (1000 * 60 * 60);
|
||||
const freshness = Math.max(0, 36 - ageHours);
|
||||
|
||||
const engagement = post.likesCount * 3 + post.commentsCount * 4 + post.savesCount * 5;
|
||||
const hashtagMatches = this.intersectionCount(
|
||||
this.buildPreferenceTokens(currentUser),
|
||||
(post.hashtags ?? []).map((x: string) => x.toLowerCase()),
|
||||
);
|
||||
|
||||
const distanceKm = this.computeDistanceKm(
|
||||
currentUser.latitude,
|
||||
currentUser.longitude,
|
||||
author?.latitude ?? null,
|
||||
author?.longitude ?? null,
|
||||
);
|
||||
const nearbyBoost =
|
||||
typeof distanceKm === 'number' && distanceKm <= radiusKm ? Math.max(0, 25 - distanceKm / 2) : 0;
|
||||
|
||||
let score = 0;
|
||||
score += engagement;
|
||||
score += freshness;
|
||||
score += isOwnPost ? 10 : 0;
|
||||
score += isFollowing ? 40 : 0;
|
||||
score += post.postType === preferredPostType ? 18 : 0;
|
||||
score += hashtagMatches * 9;
|
||||
score += nearbyBoost;
|
||||
score += author?.isVerified ? 8 : 0;
|
||||
score += Math.min(20, Math.floor((author?.followersCount ?? 0) / 200));
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private buildPreferenceTokens(user: UserDocument): string[] {
|
||||
const tokens = [
|
||||
...(user.musicGenres ?? []),
|
||||
...(user.favoriteInstruments ?? []),
|
||||
...(user.favoriteMaqamat ?? []),
|
||||
...(user.musicRoles ?? []),
|
||||
]
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return Array.from(new Set(tokens));
|
||||
}
|
||||
|
||||
private intersectionCount(a: string[], b: string[]): number {
|
||||
if (!a.length || !b.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const right = new Set(b);
|
||||
let count = 0;
|
||||
for (const item of a) {
|
||||
if (right.has(item)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private computeDistanceKm(
|
||||
lat1: number | null | undefined,
|
||||
lon1: number | null | undefined,
|
||||
lat2: number | null | undefined,
|
||||
lon2: number | null | undefined,
|
||||
): number | null {
|
||||
if (
|
||||
typeof lat1 !== 'number' ||
|
||||
typeof lon1 !== 'number' ||
|
||||
typeof lat2 !== 'number' ||
|
||||
typeof lon2 !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const earthKm = 6371;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return earthKm * c;
|
||||
}
|
||||
}
|
||||
6
src/modules/follows/dto/toggle-follow.dto.ts
Normal file
6
src/modules/follows/dto/toggle-follow.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsMongoId } from 'class-validator';
|
||||
|
||||
export class ToggleFollowDto {
|
||||
@IsMongoId()
|
||||
targetUserId!: string;
|
||||
}
|
||||
52
src/modules/follows/follows.controller.ts
Normal file
52
src/modules/follows/follows.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Get, Param, 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 { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { ToggleFollowDto } from './dto/toggle-follow.dto';
|
||||
import { FollowsService } from './follows.service';
|
||||
|
||||
@ApiTags('Follows')
|
||||
@Controller('follows')
|
||||
export class FollowsController {
|
||||
constructor(private readonly followsService: FollowsService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('toggle')
|
||||
@Throttle(30, 60_000)
|
||||
async toggleFollow(@CurrentUser() user: JwtPayload, @Body() dto: ToggleFollowDto) {
|
||||
return this.followsService.toggleFollow(user.sub, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('followers/:userId')
|
||||
async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getFollowers(userId, query.page, query.limit);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('following/:userId')
|
||||
async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getFollowing(userId, query.page, query.limit);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('status/:targetUserId')
|
||||
async status(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.followsService.getFollowStatus(user.sub, targetUserId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('suggestions')
|
||||
@Throttle(60, 60_000)
|
||||
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
|
||||
return this.followsService.getSuggestions(user.sub, query.page, query.limit);
|
||||
}
|
||||
}
|
||||
25
src/modules/follows/follows.module.ts
Normal file
25
src/modules/follows/follows.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { OutboxModule } from '../outbox/outbox.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { FollowsController } from './follows.controller';
|
||||
import { FollowsService } from './follows.service';
|
||||
import { FollowsRepository } from './follows.repository';
|
||||
import { Follow, FollowSchema } from './schemas/follow.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
OutboxModule,
|
||||
MongooseModule.forFeature([
|
||||
{
|
||||
name: Follow.name,
|
||||
schema: FollowSchema,
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [FollowsController],
|
||||
providers: [FollowsService, FollowsRepository],
|
||||
exports: [FollowsService],
|
||||
})
|
||||
export class FollowsModule {}
|
||||
65
src/modules/follows/follows.repository.ts
Normal file
65
src/modules/follows/follows.repository.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
|
||||
import { Follow, FollowDocument } from './schemas/follow.schema';
|
||||
|
||||
@Injectable()
|
||||
export class FollowsRepository {
|
||||
constructor(@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>) {}
|
||||
|
||||
async findOne(followerId: string, followingId: string): Promise<FollowDocument | null> {
|
||||
return this.followModel
|
||||
.findOne({
|
||||
followerId: new Types.ObjectId(followerId),
|
||||
followingId: new Types.ObjectId(followingId),
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async create(
|
||||
followerId: string,
|
||||
followingId: string,
|
||||
session?: ClientSession,
|
||||
): Promise<FollowDocument> {
|
||||
const [follow] = await this.followModel.create(
|
||||
[
|
||||
{
|
||||
followerId: new Types.ObjectId(followerId),
|
||||
followingId: new Types.ObjectId(followingId),
|
||||
},
|
||||
],
|
||||
{ session },
|
||||
);
|
||||
|
||||
return follow;
|
||||
}
|
||||
|
||||
async deleteById(id: string, session?: ClientSession): Promise<void> {
|
||||
await this.followModel.findByIdAndDelete(id, { session }).exec();
|
||||
}
|
||||
|
||||
async findMany(filter: FilterQuery<FollowDocument>, skip: number, limit: number): Promise<FollowDocument[]> {
|
||||
return this.followModel
|
||||
.find(filter)
|
||||
.populate({ path: 'followerId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.populate({ path: 'followingId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async count(filter: FilterQuery<FollowDocument>): Promise<number> {
|
||||
return this.followModel.countDocuments(filter).exec();
|
||||
}
|
||||
|
||||
async findFollowingIds(followerId: string): Promise<string[]> {
|
||||
const rows = await this.followModel
|
||||
.find({ followerId: new Types.ObjectId(followerId) })
|
||||
.select({ followingId: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return rows.map((row) => row.followingId.toString());
|
||||
}
|
||||
}
|
||||
42
src/modules/follows/follows.service.spec.ts
Normal file
42
src/modules/follows/follows.service.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { FollowsService } from './follows.service';
|
||||
|
||||
describe('FollowsService', () => {
|
||||
it('keeps follow successful even if notification creation fails and resyncs counters', async () => {
|
||||
const currentUserId = '507f1f77bcf86cd799439011';
|
||||
const targetUserId = '507f191e810c19729de860ea';
|
||||
|
||||
const followsRepository = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({ id: 'follow-1' }),
|
||||
count: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(6)
|
||||
.mockResolvedValueOnce(14),
|
||||
};
|
||||
const usersRepository = {
|
||||
findById: jest.fn().mockResolvedValue({ id: targetUserId }),
|
||||
setFollowingCount: jest.fn().mockResolvedValue(undefined),
|
||||
setFollowersCount: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const outboxService = {
|
||||
enqueueFollowNotification: jest.fn().mockRejectedValue(new Error('socket down')),
|
||||
};
|
||||
|
||||
const service = new FollowsService(
|
||||
followsRepository as any,
|
||||
usersRepository as any,
|
||||
outboxService as any,
|
||||
);
|
||||
|
||||
await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({
|
||||
following: true,
|
||||
});
|
||||
expect(usersRepository.setFollowingCount).toHaveBeenCalledWith(currentUserId, 6);
|
||||
expect(usersRepository.setFollowersCount).toHaveBeenCalledWith(targetUserId, 14);
|
||||
expect(outboxService.enqueueFollowNotification).toHaveBeenCalledWith(
|
||||
currentUserId,
|
||||
targetUserId,
|
||||
'follow-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
223
src/modules/follows/follows.service.ts
Normal file
223
src/modules/follows/follows.service.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { OutboxService } from '../outbox/outbox.service';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { UserDocument } from '../users/schemas/user.schema';
|
||||
import { ToggleFollowDto } from './dto/toggle-follow.dto';
|
||||
import { FollowsRepository } from './follows.repository';
|
||||
|
||||
@Injectable()
|
||||
export class FollowsService {
|
||||
private readonly logger = new Logger(FollowsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly followsRepository: FollowsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly outboxService: OutboxService,
|
||||
) {}
|
||||
|
||||
async toggleFollow(currentUserId: string, dto: ToggleFollowDto) {
|
||||
const targetUserId = dto.targetUserId;
|
||||
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new BadRequestException('You cannot follow yourself');
|
||||
}
|
||||
|
||||
const targetUser = await this.usersRepository.findById(targetUserId);
|
||||
if (!targetUser) {
|
||||
throw new NotFoundException('Target user not found');
|
||||
}
|
||||
|
||||
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
|
||||
|
||||
if (existing) {
|
||||
await this.followsRepository.deleteById(existing.id);
|
||||
await this.syncFollowCounts(currentUserId, targetUserId);
|
||||
return { following: false };
|
||||
}
|
||||
|
||||
const follow = await this.followsRepository.create(currentUserId, targetUserId);
|
||||
await this.syncFollowCounts(currentUserId, targetUserId);
|
||||
|
||||
try {
|
||||
await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Follow notification failed for actor=${currentUserId} recipient=${targetUserId}: ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { following: true };
|
||||
}
|
||||
|
||||
async getFollowers(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
this.followsRepository.findMany({ followingId: userId }, skip, limit),
|
||||
this.followsRepository.count({ followingId: userId }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async getFollowing(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
this.followsRepository.findMany({ followerId: userId }, skip, limit),
|
||||
this.followsRepository.count({ followerId: userId }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async getFollowStatus(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
|
||||
if (currentUserId === targetUserId) {
|
||||
return { following: false, targetUserId };
|
||||
}
|
||||
|
||||
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
|
||||
return {
|
||||
following: !!existing,
|
||||
targetUserId,
|
||||
};
|
||||
}
|
||||
|
||||
async getSuggestions(currentUserId: string, page = 1, limit = 20) {
|
||||
const currentUser = await this.usersRepository.findById(currentUserId);
|
||||
if (!currentUser) {
|
||||
throw new NotFoundException('Current user not found');
|
||||
}
|
||||
|
||||
const followingIds = await this.followsRepository.findFollowingIds(currentUserId);
|
||||
const excludedIds = new Set<string>([currentUserId, ...followingIds]);
|
||||
|
||||
const candidates = await this.usersRepository.findSuggestionCandidates(
|
||||
{
|
||||
_id: { $nin: Array.from(excludedIds).map((id) => new Types.ObjectId(id)) },
|
||||
isDisabled: false,
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
const ranked = candidates
|
||||
.map((candidate) => ({
|
||||
user: candidate,
|
||||
score: this.calculateSuggestionScore(currentUser, candidate),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score || b.user.followersCount - a.user.followersCount);
|
||||
|
||||
const total = ranked.length;
|
||||
const skip = (page - 1) * limit;
|
||||
const items = ranked.slice(skip, skip + limit).map((entry) => ({
|
||||
user: entry.user,
|
||||
score: entry.score,
|
||||
reasons: this.buildSuggestionReasons(currentUser, entry.user),
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
private calculateSuggestionScore(currentUser: UserDocument, candidate: UserDocument): number {
|
||||
const sharedRoles = this.intersectionCount(currentUser.musicRoles, candidate.musicRoles);
|
||||
const sharedGenres = this.intersectionCount(currentUser.musicGenres, candidate.musicGenres);
|
||||
const sharedInstruments = this.intersectionCount(
|
||||
currentUser.favoriteInstruments,
|
||||
candidate.favoriteInstruments,
|
||||
);
|
||||
const sharedMaqamat = this.intersectionCount(currentUser.favoriteMaqamat, candidate.favoriteMaqamat);
|
||||
const sameLocation = this.normalize(currentUser.location) === this.normalize(candidate.location);
|
||||
|
||||
let score = 0;
|
||||
score += sharedRoles * 15;
|
||||
score += sharedGenres * 8;
|
||||
score += sharedInstruments * 6;
|
||||
score += sharedMaqamat * 6;
|
||||
score += sameLocation ? 20 : 0;
|
||||
score += candidate.isVerified ? 25 : 0;
|
||||
score += Math.min(25, Math.floor(candidate.followersCount / 100));
|
||||
score += Math.random();
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private buildSuggestionReasons(currentUser: UserDocument, candidate: UserDocument): string[] {
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (this.intersectionCount(currentUser.musicRoles, candidate.musicRoles) > 0) {
|
||||
reasons.push('shared_music_roles');
|
||||
}
|
||||
if (this.intersectionCount(currentUser.musicGenres, candidate.musicGenres) > 0) {
|
||||
reasons.push('shared_genres');
|
||||
}
|
||||
if (this.normalize(currentUser.location) === this.normalize(candidate.location)) {
|
||||
reasons.push('same_location');
|
||||
}
|
||||
if (candidate.isVerified) {
|
||||
reasons.push('verified_account');
|
||||
}
|
||||
if (candidate.followersCount > 500) {
|
||||
reasons.push('popular_creator');
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private normalize(value?: string): string {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
private intersectionCount(a: string[] = [], b: string[] = []): number {
|
||||
if (!a.length || !b.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const right = new Set(b.map((item) => item.toLowerCase()));
|
||||
let count = 0;
|
||||
for (const item of a) {
|
||||
if (right.has(item.toLowerCase())) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncFollowCounts(currentUserId: string, targetUserId: string): Promise<void> {
|
||||
const [followingCount, followersCount] = await Promise.all([
|
||||
this.followsRepository.count({ followerId: currentUserId }),
|
||||
this.followsRepository.count({ followingId: targetUserId }),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
this.usersRepository.setFollowingCount(currentUserId, followingCount),
|
||||
this.usersRepository.setFollowersCount(targetUserId, followersCount),
|
||||
]);
|
||||
}
|
||||
}
|
||||
17
src/modules/follows/schemas/follow.schema.ts
Normal file
17
src/modules/follows/schemas/follow.schema.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type FollowDocument = HydratedDocument<Follow>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Follow {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
followerId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
followingId!: Types.ObjectId;
|
||||
}
|
||||
|
||||
export const FollowSchema = SchemaFactory.createForClass(Follow);
|
||||
FollowSchema.index({ followerId: 1, followingId: 1 }, { unique: true });
|
||||
12
src/modules/likes/dto/toggle-like.dto.ts
Normal file
12
src/modules/likes/dto/toggle-like.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsIn, IsMongoId } from 'class-validator';
|
||||
|
||||
export class ToggleLikeDto {
|
||||
@ApiProperty()
|
||||
@IsMongoId()
|
||||
targetId!: string;
|
||||
|
||||
@ApiProperty({ enum: ['post', 'comment'] })
|
||||
@IsIn(['post', 'comment'])
|
||||
targetType!: 'post' | 'comment';
|
||||
}
|
||||
43
src/modules/likes/likes.controller.ts
Normal file
43
src/modules/likes/likes.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, 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 { ToggleLikeDto } from './dto/toggle-like.dto';
|
||||
import { LikesService } from './likes.service';
|
||||
|
||||
@ApiTags('Likes')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('likes')
|
||||
export class LikesController {
|
||||
constructor(private readonly likesService: LikesService) {}
|
||||
|
||||
@Post()
|
||||
async like(@CurrentUser() user: JwtPayload, @Body() dto: ToggleLikeDto) {
|
||||
return this.likesService.like(user.sub, dto);
|
||||
}
|
||||
|
||||
@Delete(':targetType/:targetId')
|
||||
async unlike(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('targetId') targetId: string,
|
||||
@Param('targetType') targetType: 'post' | 'comment',
|
||||
) {
|
||||
return this.likesService.unlike(user.sub, { targetId, targetType });
|
||||
}
|
||||
|
||||
@Get('status/:targetType/:targetId')
|
||||
async getStatus(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('targetId') targetId: string,
|
||||
@Param('targetType') targetType: 'post' | 'comment',
|
||||
) {
|
||||
return this.likesService.getStatus(user.sub, { targetId, targetType });
|
||||
}
|
||||
|
||||
@Post('toggle')
|
||||
async toggle(@CurrentUser() user: JwtPayload, @Body() dto: ToggleLikeDto) {
|
||||
return this.likesService.toggle(user.sub, dto);
|
||||
}
|
||||
}
|
||||
20
src/modules/likes/likes.module.ts
Normal file
20
src/modules/likes/likes.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { CommentsModule } from '../comments/comments.module';
|
||||
import { PostsModule } from '../posts/posts.module';
|
||||
import { Like, LikeSchema } from './schemas/like.schema';
|
||||
import { LikesController } from './likes.controller';
|
||||
import { LikesRepository } from './likes.repository';
|
||||
import { LikesService } from './likes.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([{ name: Like.name, schema: LikeSchema }]),
|
||||
PostsModule,
|
||||
CommentsModule,
|
||||
],
|
||||
controllers: [LikesController],
|
||||
providers: [LikesService, LikesRepository],
|
||||
exports: [LikesService],
|
||||
})
|
||||
export class LikesModule {}
|
||||
31
src/modules/likes/likes.repository.ts
Normal file
31
src/modules/likes/likes.repository.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import { Like, LikeDocument } from './schemas/like.schema';
|
||||
|
||||
@Injectable()
|
||||
export class LikesRepository {
|
||||
constructor(@InjectModel(Like.name) private readonly likeModel: Model<LikeDocument>) {}
|
||||
|
||||
async findOne(userId: string, targetId: string, targetType: 'post' | 'comment'): Promise<LikeDocument | null> {
|
||||
return this.likeModel
|
||||
.findOne({
|
||||
userId: new Types.ObjectId(userId),
|
||||
targetId: new Types.ObjectId(targetId),
|
||||
targetType,
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async create(userId: string, targetId: string, targetType: 'post' | 'comment'): Promise<LikeDocument> {
|
||||
return this.likeModel.create({
|
||||
userId: new Types.ObjectId(userId),
|
||||
targetId: new Types.ObjectId(targetId),
|
||||
targetType,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.likeModel.findByIdAndDelete(id).exec();
|
||||
}
|
||||
}
|
||||
30
src/modules/likes/likes.service.spec.ts
Normal file
30
src/modules/likes/likes.service.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { LikesService } from './likes.service';
|
||||
|
||||
describe('LikesService', () => {
|
||||
it('returns liked false from status when target post no longer exists', async () => {
|
||||
const likesRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const postsRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const commentsRepository = {
|
||||
findById: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new LikesService(
|
||||
likesRepository as any,
|
||||
postsRepository as any,
|
||||
commentsRepository as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getStatus('user-1', { targetId: '507f1f77bcf86cd799439011', targetType: 'post' }),
|
||||
).resolves.toEqual({
|
||||
liked: false,
|
||||
targetId: '507f1f77bcf86cd799439011',
|
||||
targetType: 'post',
|
||||
});
|
||||
expect(likesRepository.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
78
src/modules/likes/likes.service.ts
Normal file
78
src/modules/likes/likes.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CommentsRepository } from '../comments/comments.repository';
|
||||
import { PostsRepository } from '../posts/posts.repository';
|
||||
import { LikesRepository } from './likes.repository';
|
||||
import { ToggleLikeDto } from './dto/toggle-like.dto';
|
||||
|
||||
@Injectable()
|
||||
export class LikesService {
|
||||
constructor(
|
||||
private readonly likesRepository: LikesRepository,
|
||||
private readonly postsRepository: PostsRepository,
|
||||
private readonly commentsRepository: CommentsRepository,
|
||||
) {}
|
||||
|
||||
async toggle(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
||||
return existing ? this.unlike(userId, dto) : this.like(userId, dto);
|
||||
}
|
||||
|
||||
async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||
await this.assertTargetExists(dto);
|
||||
|
||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
||||
if (existing) {
|
||||
return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
|
||||
await this.likesRepository.create(userId, dto.targetId, dto.targetType);
|
||||
if (dto.targetType === 'post') {
|
||||
await this.postsRepository.incrementLikesCount(dto.targetId, 1);
|
||||
}
|
||||
|
||||
return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
|
||||
async unlike(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||
await this.assertTargetExists(dto);
|
||||
|
||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
||||
if (!existing) {
|
||||
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
|
||||
await this.likesRepository.deleteById(existing.id);
|
||||
if (dto.targetType === 'post') {
|
||||
await this.postsRepository.incrementLikesCount(dto.targetId, -1);
|
||||
}
|
||||
|
||||
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
|
||||
async getStatus(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
|
||||
const targetExists = await this.targetExists(dto);
|
||||
if (!targetExists) {
|
||||
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
|
||||
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
|
||||
return { liked: !!existing, targetId: dto.targetId, targetType: dto.targetType };
|
||||
}
|
||||
|
||||
private async assertTargetExists(dto: ToggleLikeDto): Promise<void> {
|
||||
const targetExists = await this.targetExists(dto);
|
||||
if (!targetExists) {
|
||||
throw new NotFoundException(dto.targetType === 'post' ? 'Post not found' : 'Comment not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async targetExists(dto: ToggleLikeDto): Promise<boolean> {
|
||||
if (dto.targetType === 'post') {
|
||||
const post = await this.postsRepository.findById(dto.targetId);
|
||||
return !!post;
|
||||
}
|
||||
|
||||
const comment = await this.commentsRepository.findById(dto.targetId);
|
||||
return !!comment;
|
||||
}
|
||||
}
|
||||
19
src/modules/likes/schemas/like.schema.ts
Normal file
19
src/modules/likes/schemas/like.schema.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
|
||||
export type LikeDocument = HydratedDocument<Like>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Like {
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
userId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
targetId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true, enum: ['post', 'comment'] })
|
||||
targetType!: 'post' | 'comment';
|
||||
}
|
||||
|
||||
export const LikeSchema = SchemaFactory.createForClass(Like);
|
||||
LikeSchema.index({ userId: 1, targetId: 1, targetType: 1 }, { unique: true });
|
||||
50
src/modules/marketplace/dto/create-instrument.dto.ts
Normal file
50
src/modules/marketplace/dto/create-instrument.dto.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateInstrumentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
description?: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
quantity!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(5)
|
||||
@IsString({ each: true })
|
||||
imageUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
33
src/modules/marketplace/dto/instrument-query.dto.ts
Normal file
33
src/modules/marketplace/dto/instrument-query.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export class InstrumentQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minPrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxPrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number;
|
||||
}
|
||||
4
src/modules/marketplace/dto/update-instrument.dto.ts
Normal file
4
src/modules/marketplace/dto/update-instrument.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateInstrumentDto } from './create-instrument.dto';
|
||||
|
||||
export class UpdateInstrumentDto extends PartialType(CreateInstrumentDto) {}
|
||||
69
src/modules/marketplace/marketplace.controller.ts
Normal file
69
src/modules/marketplace/marketplace.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { UserRole } from '../../common/enums/user-role.enum';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { CreateInstrumentDto } from './dto/create-instrument.dto';
|
||||
import { InstrumentQueryDto } from './dto/instrument-query.dto';
|
||||
import { UpdateInstrumentDto } from './dto/update-instrument.dto';
|
||||
import { MarketplaceService } from './marketplace.service';
|
||||
|
||||
@ApiTags('Marketplace')
|
||||
@Controller('marketplace')
|
||||
export class MarketplaceController {
|
||||
constructor(private readonly marketplaceService: MarketplaceService) {}
|
||||
|
||||
@Get('instruments')
|
||||
async listPublic(@Query() query: InstrumentQueryDto) {
|
||||
return this.marketplaceService.getPublic(query);
|
||||
}
|
||||
|
||||
@Get('instruments/:id')
|
||||
async findOne(@Param('id') instrumentId: string) {
|
||||
return this.marketplaceService.findById(instrumentId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Post('admin/instruments')
|
||||
@Throttle(40, 60_000)
|
||||
async createByAdmin(@CurrentUser() user: JwtPayload, @Body() dto: CreateInstrumentDto) {
|
||||
return this.marketplaceService.createByAdmin(user.sub, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Patch('admin/instruments/:id')
|
||||
@Throttle(60, 60_000)
|
||||
async updateByAdmin(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') instrumentId: string,
|
||||
@Body() dto: UpdateInstrumentDto,
|
||||
) {
|
||||
return this.marketplaceService.updateByAdmin(user.sub, instrumentId, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Delete('admin/instruments/:id')
|
||||
@Throttle(40, 60_000)
|
||||
async removeByAdmin(@CurrentUser() user: JwtPayload, @Param('id') instrumentId: string) {
|
||||
await this.marketplaceService.removeByAdmin(user.sub, instrumentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('admin/instruments/me')
|
||||
async myInstruments(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) {
|
||||
return this.marketplaceService.getMine(user.sub, query);
|
||||
}
|
||||
}
|
||||
18
src/modules/marketplace/marketplace.module.ts
Normal file
18
src/modules/marketplace/marketplace.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { MarketplaceController } from './marketplace.controller';
|
||||
import { MarketplaceRepository } from './marketplace.repository';
|
||||
import { MarketplaceService } from './marketplace.service';
|
||||
import { Instrument, InstrumentSchema } from './schemas/instrument.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
MongooseModule.forFeature([{ name: Instrument.name, schema: InstrumentSchema }]),
|
||||
],
|
||||
controllers: [MarketplaceController],
|
||||
providers: [MarketplaceService, MarketplaceRepository],
|
||||
exports: [MarketplaceService],
|
||||
})
|
||||
export class MarketplaceModule {}
|
||||
135
src/modules/marketplace/marketplace.repository.ts
Normal file
135
src/modules/marketplace/marketplace.repository.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
|
||||
import { Instrument, InstrumentDocument } from './schemas/instrument.schema';
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceRepository {
|
||||
constructor(
|
||||
@InjectModel(Instrument.name)
|
||||
private readonly instrumentModel: Model<InstrumentDocument>,
|
||||
) {}
|
||||
|
||||
async create(ownerAdminId: string, payload: Partial<Instrument>): Promise<InstrumentDocument> {
|
||||
return this.instrumentModel.create({
|
||||
...payload,
|
||||
ownerAdminId: new Types.ObjectId(ownerAdminId),
|
||||
});
|
||||
}
|
||||
|
||||
async findById(instrumentId: string): Promise<InstrumentDocument | null> {
|
||||
if (!Types.ObjectId.isValid(instrumentId)) {
|
||||
return null;
|
||||
}
|
||||
return this.instrumentModel
|
||||
.findById(instrumentId)
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateById(
|
||||
instrumentId: string,
|
||||
payload: UpdateQuery<InstrumentDocument>,
|
||||
): Promise<InstrumentDocument | null> {
|
||||
if (!Types.ObjectId.isValid(instrumentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.instrumentModel
|
||||
.findByIdAndUpdate(instrumentId, payload, { new: true })
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async deleteById(instrumentId: string): Promise<InstrumentDocument | null> {
|
||||
if (!Types.ObjectId.isValid(instrumentId)) {
|
||||
return null;
|
||||
}
|
||||
return this.instrumentModel.findByIdAndDelete(instrumentId).exec();
|
||||
}
|
||||
|
||||
async findMany(
|
||||
filter: FilterQuery<InstrumentDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
): Promise<InstrumentDocument[]> {
|
||||
return this.instrumentModel
|
||||
.find(filter)
|
||||
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findManyPublic(
|
||||
filter: FilterQuery<InstrumentDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return this.instrumentModel
|
||||
.aggregate([
|
||||
{ $match: filter },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'ownerAdminId',
|
||||
foreignField: '_id',
|
||||
as: 'ownerAdmin',
|
||||
},
|
||||
},
|
||||
{ $unwind: '$ownerAdmin' },
|
||||
{ $match: { 'ownerAdmin.isDisabled': false } },
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
title: 1,
|
||||
description: 1,
|
||||
price: 1,
|
||||
currency: 1,
|
||||
quantity: 1,
|
||||
imageUrls: 1,
|
||||
isActive: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
ownerAdminId: {
|
||||
_id: '$ownerAdmin._id',
|
||||
name: '$ownerAdmin.name',
|
||||
username: '$ownerAdmin.username',
|
||||
email: '$ownerAdmin.email',
|
||||
avatar: '$ownerAdmin.avatar',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countPublic(filter: FilterQuery<InstrumentDocument>): Promise<number> {
|
||||
const rows = await this.instrumentModel
|
||||
.aggregate([
|
||||
{ $match: filter },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'ownerAdminId',
|
||||
foreignField: '_id',
|
||||
as: 'ownerAdmin',
|
||||
},
|
||||
},
|
||||
{ $unwind: '$ownerAdmin' },
|
||||
{ $match: { 'ownerAdmin.isDisabled': false } },
|
||||
{ $count: 'count' },
|
||||
])
|
||||
.exec();
|
||||
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
async count(filter: FilterQuery<InstrumentDocument>): Promise<number> {
|
||||
return this.instrumentModel.countDocuments(filter).exec();
|
||||
}
|
||||
}
|
||||
168
src/modules/marketplace/marketplace.service.ts
Normal file
168
src/modules/marketplace/marketplace.service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FilterQuery, Types } from 'mongoose';
|
||||
import { UserRole } from '../../common/enums/user-role.enum';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { CreateInstrumentDto } from './dto/create-instrument.dto';
|
||||
import { InstrumentQueryDto } from './dto/instrument-query.dto';
|
||||
import { UpdateInstrumentDto } from './dto/update-instrument.dto';
|
||||
import { MarketplaceRepository } from './marketplace.repository';
|
||||
import { InstrumentDocument } from './schemas/instrument.schema';
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceService {
|
||||
constructor(
|
||||
private readonly marketplaceRepository: MarketplaceRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
) {}
|
||||
|
||||
async createByAdmin(adminUserId: string, dto: CreateInstrumentDto): Promise<InstrumentDocument> {
|
||||
await this.assertAdminRole(adminUserId);
|
||||
this.assertImageUrlsCount(dto.imageUrls);
|
||||
return this.marketplaceRepository.create(adminUserId, {
|
||||
...dto,
|
||||
currency: (dto.currency ?? 'SAR').toUpperCase(),
|
||||
description: dto.description ?? '',
|
||||
imageUrls: dto.imageUrls ?? [],
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
async updateByAdmin(
|
||||
adminUserId: string,
|
||||
instrumentId: string,
|
||||
dto: UpdateInstrumentDto,
|
||||
): Promise<InstrumentDocument> {
|
||||
await this.assertAdminRole(adminUserId);
|
||||
this.assertImageUrlsCount(dto.imageUrls);
|
||||
const existing = await this.marketplaceRepository.findById(instrumentId);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Instrument not found');
|
||||
}
|
||||
if (existing.ownerAdminId.toString() !== adminUserId) {
|
||||
throw new ForbiddenException('You can update only your instruments');
|
||||
}
|
||||
|
||||
const updated = await this.marketplaceRepository.updateById(instrumentId, {
|
||||
...dto,
|
||||
...(dto.currency ? { currency: dto.currency.toUpperCase() } : {}),
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Instrument not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async removeByAdmin(adminUserId: string, instrumentId: string): Promise<void> {
|
||||
await this.assertAdminRole(adminUserId);
|
||||
const existing = await this.marketplaceRepository.findById(instrumentId);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Instrument not found');
|
||||
}
|
||||
if (existing.ownerAdminId.toString() !== adminUserId) {
|
||||
throw new ForbiddenException('You can delete only your instruments');
|
||||
}
|
||||
await this.marketplaceRepository.deleteById(instrumentId);
|
||||
}
|
||||
|
||||
async getMine(adminUserId: string, query: InstrumentQueryDto) {
|
||||
await this.assertAdminRole(adminUserId);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const filter = this.buildFilter(query);
|
||||
filter.ownerAdminId = new Types.ObjectId(adminUserId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.marketplaceRepository.findManyPublic(filter, skip, limit),
|
||||
this.marketplaceRepository.countPublic(filter),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async getPublic(query: InstrumentQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const filter = this.buildFilter(query);
|
||||
if (typeof query.isActive === 'undefined') {
|
||||
filter.isActive = true;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.marketplaceRepository.findMany(filter, skip, limit),
|
||||
this.marketplaceRepository.count(filter),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(instrumentId: string) {
|
||||
const item = await this.marketplaceRepository.findById(instrumentId);
|
||||
if (!item) {
|
||||
throw new NotFoundException('Instrument not found');
|
||||
}
|
||||
const owner = item.ownerAdminId as unknown as { isDisabled?: boolean } | undefined;
|
||||
if (owner?.isDisabled) {
|
||||
throw new NotFoundException('Instrument not found');
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private buildFilter(query: InstrumentQueryDto): FilterQuery<InstrumentDocument> {
|
||||
const filter: FilterQuery<InstrumentDocument> = {};
|
||||
|
||||
if (query.q) {
|
||||
filter.$or = [
|
||||
{ title: { $regex: query.q, $options: 'i' } },
|
||||
{ description: { $regex: query.q, $options: 'i' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (typeof query.minPrice === 'number' || typeof query.maxPrice === 'number') {
|
||||
filter.price = {};
|
||||
if (typeof query.minPrice === 'number') {
|
||||
(filter.price as Record<string, number>).$gte = query.minPrice;
|
||||
}
|
||||
if (typeof query.maxPrice === 'number') {
|
||||
(filter.price as Record<string, number>).$lte = query.maxPrice;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof query.isActive === 'boolean') {
|
||||
filter.isActive = query.isActive;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
private async assertAdminRole(userId: string): Promise<void> {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
if (user.role !== UserRole.ADMIN) {
|
||||
throw new ForbiddenException('Admin role required');
|
||||
}
|
||||
}
|
||||
|
||||
private assertImageUrlsCount(imageUrls?: string[]): void {
|
||||
if (imageUrls && imageUrls.length > 5) {
|
||||
throw new BadRequestException('You can upload up to 5 images only');
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/modules/marketplace/schemas/instrument.schema.ts
Normal file
37
src/modules/marketplace/schemas/instrument.schema.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type InstrumentDocument = HydratedDocument<Instrument>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Instrument {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
ownerAdminId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true, trim: true, maxlength: 120, index: true })
|
||||
title!: string;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 2000 })
|
||||
description!: string;
|
||||
|
||||
@Prop({ required: true, min: 0 })
|
||||
price!: number;
|
||||
|
||||
@Prop({ default: 'SAR', trim: true, maxlength: 8, uppercase: true })
|
||||
currency!: string;
|
||||
|
||||
@Prop({ required: true, min: 0, default: 1 })
|
||||
quantity!: number;
|
||||
|
||||
@Prop({ type: [String], default: [] })
|
||||
imageUrls!: string[];
|
||||
|
||||
@Prop({ default: true, index: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
export const InstrumentSchema = SchemaFactory.createForClass(Instrument);
|
||||
InstrumentSchema.index({ ownerAdminId: 1, createdAt: -1 });
|
||||
InstrumentSchema.index({ isActive: 1, createdAt: -1 });
|
||||
InstrumentSchema.index({ title: 1, isActive: 1, createdAt: -1 });
|
||||
7
src/modules/media/dto/upload-media.dto.ts
Normal file
7
src/modules/media/dto/upload-media.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UploadMediaDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
folder?: string;
|
||||
}
|
||||
6
src/modules/media/media.controller.ts
Normal file
6
src/modules/media/media.controller.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('Media')
|
||||
@Controller('media')
|
||||
export class MediaController {}
|
||||
11
src/modules/media/media.module.ts
Normal file
11
src/modules/media/media.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MediaController } from './media.controller';
|
||||
import { MediaService } from './media.service';
|
||||
import { MediaRepository } from './media.repository';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService, MediaRepository],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
4
src/modules/media/media.repository.ts
Normal file
4
src/modules/media/media.repository.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class MediaRepository {}
|
||||
4
src/modules/media/media.service.ts
Normal file
4
src/modules/media/media.service.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {}
|
||||
22
src/modules/media/schemas/media-file.schema.ts
Normal file
22
src/modules/media/schemas/media-file.schema.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
|
||||
export type MediaFileDocument = HydratedDocument<MediaFile>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class MediaFile {
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
ownerId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true })
|
||||
url!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
mimeType!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
size!: number;
|
||||
}
|
||||
|
||||
export const MediaFileSchema = SchemaFactory.createForClass(MediaFile);
|
||||
MediaFileSchema.index({ ownerId: 1, createdAt: -1 });
|
||||
16
src/modules/notifications/dto/create-notification.dto.ts
Normal file
16
src/modules/notifications/dto/create-notification.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { IsEnum, IsMongoId, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateNotificationDto {
|
||||
@IsMongoId()
|
||||
recipientId!: string;
|
||||
|
||||
@IsMongoId()
|
||||
actorId!: string;
|
||||
|
||||
@IsEnum(['like', 'comment', 'follow', 'message'])
|
||||
type!: 'like' | 'comment' | 'follow' | 'message';
|
||||
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
referenceId?: string;
|
||||
}
|
||||
14
src/modules/notifications/dto/notification-query.dto.ts
Normal file
14
src/modules/notifications/dto/notification-query.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class NotificationQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === 'true' || value === true) return true;
|
||||
if (value === 'false' || value === false) return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
read?: boolean;
|
||||
}
|
||||
35
src/modules/notifications/notifications.controller.ts
Normal file
35
src/modules/notifications/notifications.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { NotificationQueryDto } from './dto/notification-query.dto';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
@Get()
|
||||
async getMine(@CurrentUser() user: JwtPayload, @Query() query: NotificationQueryDto) {
|
||||
return this.notificationsService.getMine(user.sub, query);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
async getUnreadCount(@CurrentUser() user: JwtPayload) {
|
||||
return this.notificationsService.getUnreadCount(user.sub);
|
||||
}
|
||||
|
||||
@Patch('read-all')
|
||||
async markAllRead(@CurrentUser() user: JwtPayload) {
|
||||
return this.notificationsService.markAllRead(user.sub);
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
async markRead(@CurrentUser() user: JwtPayload, @Param('id') notificationId: string) {
|
||||
return this.notificationsService.markRead(user.sub, notificationId);
|
||||
}
|
||||
}
|
||||
71
src/modules/notifications/notifications.gateway.ts
Normal file
71
src/modules/notifications/notifications.gateway.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
type SocketWithUser = Socket & { data: { userId?: string } };
|
||||
|
||||
@WebSocketGateway({ cors: { origin: '*' }, namespace: 'notifications' })
|
||||
export class NotificationsGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
constructor(
|
||||
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));
|
||||
} catch {
|
||||
client.disconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
emitCreated(recipientId: string, notification: unknown, unreadCount: number): void {
|
||||
this.server.to(this.userRoom(recipientId)).emit('notification_created', notification);
|
||||
this.server.to(this.userRoom(recipientId)).emit('notifications_unread_count', { unreadCount });
|
||||
}
|
||||
|
||||
emitUnreadCount(recipientId: string, unreadCount: number): void {
|
||||
this.server.to(this.userRoom(recipientId)).emit('notifications_unread_count', { unreadCount });
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
21
src/modules/notifications/notifications.module.ts
Normal file
21
src/modules/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsGateway } from './notifications.gateway';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationsRepository } from './notifications.repository';
|
||||
import { Notification, NotificationSchema } from './schemas/notification.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
JwtModule.register({}),
|
||||
MongooseModule.forFeature([{ name: Notification.name, schema: NotificationSchema }]),
|
||||
],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService, NotificationsRepository, NotificationsGateway],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
100
src/modules/notifications/notifications.repository.ts
Normal file
100
src/modules/notifications/notifications.repository.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types } from 'mongoose';
|
||||
import { Notification, NotificationDocument } from './schemas/notification.schema';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsRepository {
|
||||
constructor(
|
||||
@InjectModel(Notification.name)
|
||||
private readonly notificationModel: Model<NotificationDocument>,
|
||||
) {}
|
||||
|
||||
async create(payload: Partial<Notification>): Promise<NotificationDocument> {
|
||||
const notification = await this.notificationModel.create(payload);
|
||||
const hydrated = await this.findById(notification.id);
|
||||
if (!hydrated) {
|
||||
throw new Error('Notification was created but could not be reloaded');
|
||||
}
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<NotificationDocument | null> {
|
||||
return this.notificationModel
|
||||
.findById(id)
|
||||
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findMine(
|
||||
recipientId: string,
|
||||
filter: FilterQuery<NotificationDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
): Promise<NotificationDocument[]> {
|
||||
return this.notificationModel
|
||||
.find({
|
||||
recipientId: new Types.ObjectId(recipientId),
|
||||
...filter,
|
||||
})
|
||||
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countMine(recipientId: string, filter: FilterQuery<NotificationDocument>): Promise<number> {
|
||||
return this.notificationModel
|
||||
.countDocuments({
|
||||
recipientId: new Types.ObjectId(recipientId),
|
||||
...filter,
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countUnread(recipientId: string): Promise<number> {
|
||||
return this.notificationModel
|
||||
.countDocuments({
|
||||
recipientId: new Types.ObjectId(recipientId),
|
||||
read: false,
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async markRead(recipientId: string, notificationId: string): Promise<NotificationDocument | null> {
|
||||
const updated = await this.notificationModel
|
||||
.findOneAndUpdate(
|
||||
{
|
||||
_id: new Types.ObjectId(notificationId),
|
||||
recipientId: new Types.ObjectId(recipientId),
|
||||
},
|
||||
{
|
||||
read: true,
|
||||
readAt: new Date(),
|
||||
},
|
||||
{ new: true },
|
||||
)
|
||||
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.exec();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async markAllRead(recipientId: string): Promise<number> {
|
||||
const result = await this.notificationModel
|
||||
.updateMany(
|
||||
{
|
||||
recipientId: new Types.ObjectId(recipientId),
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
read: true,
|
||||
readAt: new Date(),
|
||||
},
|
||||
)
|
||||
.exec();
|
||||
|
||||
return result.modifiedCount ?? 0;
|
||||
}
|
||||
}
|
||||
44
src/modules/notifications/notifications.service.spec.ts
Normal file
44
src/modules/notifications/notifications.service.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
describe('NotificationsService', () => {
|
||||
it('recalculates unread count after markAllRead', async () => {
|
||||
const notificationsRepository = {
|
||||
markAllRead: jest.fn().mockResolvedValue(4),
|
||||
countUnread: jest.fn().mockResolvedValue(2),
|
||||
};
|
||||
const notificationsGateway = {
|
||||
emitUnreadCount: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new NotificationsService(
|
||||
notificationsRepository as any,
|
||||
notificationsGateway as any,
|
||||
);
|
||||
|
||||
await expect(service.markAllRead('user-1')).resolves.toEqual({
|
||||
message: 'All notifications marked as read',
|
||||
updatedCount: 4,
|
||||
unreadCount: 2,
|
||||
});
|
||||
expect(notificationsGateway.emitUnreadCount).toHaveBeenCalledWith('user-1', 2);
|
||||
});
|
||||
|
||||
it('throws not found for invalid notification id in markRead', async () => {
|
||||
const notificationsRepository = {
|
||||
markRead: jest.fn(),
|
||||
countUnread: jest.fn(),
|
||||
};
|
||||
const notificationsGateway = {
|
||||
emitUnreadCount: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new NotificationsService(
|
||||
notificationsRepository as any,
|
||||
notificationsGateway as any,
|
||||
);
|
||||
|
||||
await expect(service.markRead('user-1', 'invalid-id')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(notificationsRepository.markRead).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
107
src/modules/notifications/notifications.service.ts
Normal file
107
src/modules/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { CreateNotificationDto } from './dto/create-notification.dto';
|
||||
import { NotificationQueryDto } from './dto/notification-query.dto';
|
||||
import { NotificationsGateway } from './notifications.gateway';
|
||||
import { NotificationsRepository } from './notifications.repository';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(
|
||||
private readonly notificationsRepository: NotificationsRepository,
|
||||
private readonly notificationsGateway: NotificationsGateway,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateNotificationDto) {
|
||||
if (dto.recipientId === dto.actorId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const notification = await this.notificationsRepository.create({
|
||||
recipientId: new Types.ObjectId(dto.recipientId),
|
||||
actorId: new Types.ObjectId(dto.actorId),
|
||||
type: dto.type,
|
||||
referenceId: dto.referenceId ? new Types.ObjectId(dto.referenceId) : undefined,
|
||||
read: false,
|
||||
readAt: null,
|
||||
});
|
||||
|
||||
const unreadCount = await this.notificationsRepository.countUnread(dto.recipientId);
|
||||
this.notificationsGateway.emitCreated(dto.recipientId, notification.toJSON(), unreadCount);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
async createFollowNotification(actorId: string, recipientId: string, referenceId?: string) {
|
||||
return this.create({
|
||||
actorId,
|
||||
recipientId,
|
||||
type: 'follow',
|
||||
referenceId,
|
||||
});
|
||||
}
|
||||
|
||||
async getMine(recipientId: string, query: NotificationQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
if (typeof query.read === 'boolean') {
|
||||
filter.read = query.read;
|
||||
}
|
||||
|
||||
const [items, total, unreadCount] = await Promise.all([
|
||||
this.notificationsRepository.findMine(recipientId, filter, skip, limit),
|
||||
this.notificationsRepository.countMine(recipientId, filter),
|
||||
this.notificationsRepository.countUnread(recipientId),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
unreadCount,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async getUnreadCount(recipientId: string) {
|
||||
return {
|
||||
unreadCount: await this.notificationsRepository.countUnread(recipientId),
|
||||
};
|
||||
}
|
||||
|
||||
async markRead(recipientId: string, notificationId: string) {
|
||||
if (!Types.ObjectId.isValid(notificationId)) {
|
||||
throw new NotFoundException('Notification not found');
|
||||
}
|
||||
|
||||
const notification = await this.notificationsRepository.markRead(recipientId, notificationId);
|
||||
if (!notification) {
|
||||
throw new NotFoundException('Notification not found');
|
||||
}
|
||||
|
||||
const unreadCount = await this.notificationsRepository.countUnread(recipientId);
|
||||
this.notificationsGateway.emitUnreadCount(recipientId, unreadCount);
|
||||
|
||||
return {
|
||||
message: 'Notification marked as read',
|
||||
unreadCount,
|
||||
item: notification,
|
||||
};
|
||||
}
|
||||
|
||||
async markAllRead(recipientId: string) {
|
||||
const updatedCount = await this.notificationsRepository.markAllRead(recipientId);
|
||||
const unreadCount = await this.notificationsRepository.countUnread(recipientId);
|
||||
this.notificationsGateway.emitUnreadCount(recipientId, unreadCount);
|
||||
|
||||
return {
|
||||
message: 'All notifications marked as read',
|
||||
updatedCount,
|
||||
unreadCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
31
src/modules/notifications/schemas/notification.schema.ts
Normal file
31
src/modules/notifications/schemas/notification.schema.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type NotificationDocument = HydratedDocument<Notification>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Notification {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
recipientId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
actorId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true, enum: ['like', 'comment', 'follow', 'message'] })
|
||||
type!: 'like' | 'comment' | 'follow' | 'message';
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
referenceId?: Types.ObjectId;
|
||||
|
||||
@Prop({ default: false })
|
||||
read!: boolean;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
readAt?: Date | null;
|
||||
}
|
||||
|
||||
export const NotificationSchema = SchemaFactory.createForClass(Notification);
|
||||
NotificationSchema.index({ recipientId: 1, createdAt: -1 });
|
||||
NotificationSchema.index({ recipientId: 1, read: 1, createdAt: -1 });
|
||||
NotificationSchema.index({ recipientId: 1, read: 1, type: 1, createdAt: -1 });
|
||||
15
src/modules/outbox/outbox.module.ts
Normal file
15
src/modules/outbox/outbox.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { OutboxService } from './outbox.service';
|
||||
import { OutboxEvent, OutboxEventSchema } from './schemas/outbox-event.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([{ name: OutboxEvent.name, schema: OutboxEventSchema }]),
|
||||
NotificationsModule,
|
||||
],
|
||||
providers: [OutboxService],
|
||||
exports: [OutboxService],
|
||||
})
|
||||
export class OutboxModule {}
|
||||
57
src/modules/outbox/outbox.service.ts
Normal file
57
src/modules/outbox/outbox.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { OutboxEvent, OutboxEventDocument } from './schemas/outbox-event.schema';
|
||||
|
||||
@Injectable()
|
||||
export class OutboxService {
|
||||
private readonly logger = new Logger(OutboxService.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(OutboxEvent.name) private readonly outboxEventModel: Model<OutboxEventDocument>,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async enqueueFollowNotification(actorId: string, recipientId: string, referenceId?: string): Promise<void> {
|
||||
const event = await this.outboxEventModel.create({
|
||||
eventType: 'follow_notification',
|
||||
payload: {
|
||||
actorId,
|
||||
recipientId,
|
||||
referenceId: referenceId ?? '',
|
||||
},
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
await this.processEvent(event.id);
|
||||
}
|
||||
|
||||
async processEvent(eventId: string): Promise<void> {
|
||||
const event = await this.outboxEventModel.findById(eventId).exec();
|
||||
if (!event || event.status === 'processed') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (event.eventType === 'follow_notification') {
|
||||
await this.notificationsService.createFollowNotification(
|
||||
String(event.payload.actorId ?? ''),
|
||||
String(event.payload.recipientId ?? ''),
|
||||
String(event.payload.referenceId ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
event.status = 'processed';
|
||||
event.processedAt = new Date();
|
||||
event.lastError = '';
|
||||
} catch (error) {
|
||||
event.status = 'failed';
|
||||
event.lastError = error instanceof Error ? error.message : 'unknown outbox error';
|
||||
this.logger.warn(`Outbox event ${event.id} failed: ${event.lastError}`);
|
||||
} finally {
|
||||
event.attempts += 1;
|
||||
await event.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/modules/outbox/schemas/outbox-event.schema.ts
Normal file
28
src/modules/outbox/schemas/outbox-event.schema.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument } from 'mongoose';
|
||||
|
||||
export type OutboxEventDocument = HydratedDocument<OutboxEvent>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class OutboxEvent {
|
||||
@Prop({ required: true, index: true })
|
||||
eventType!: string;
|
||||
|
||||
@Prop({ required: true, type: Object })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@Prop({ required: true, default: 'pending', enum: ['pending', 'processed', 'failed'], index: true })
|
||||
status!: 'pending' | 'processed' | 'failed';
|
||||
|
||||
@Prop({ required: true, default: 0, min: 0 })
|
||||
attempts!: number;
|
||||
|
||||
@Prop({ default: '' })
|
||||
lastError!: string;
|
||||
|
||||
@Prop({ type: Date, default: null, index: true })
|
||||
processedAt?: Date | null;
|
||||
}
|
||||
|
||||
export const OutboxEventSchema = SchemaFactory.createForClass(OutboxEvent);
|
||||
OutboxEventSchema.index({ status: 1, createdAt: 1 });
|
||||
25
src/modules/posts/dto/create-post.dto.ts
Normal file
25
src/modules/posts/dto/create-post.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
|
||||
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
|
||||
|
||||
export class CreatePostDto {
|
||||
@ApiProperty({ maxLength: 2200, description: 'Post description/content' })
|
||||
@IsString()
|
||||
@Length(1, 2200)
|
||||
content!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Single video URL (optional)' })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
videoUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Single audio URL (optional)' })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
audioUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PostVisibility, default: PostVisibility.PUBLIC })
|
||||
@IsOptional()
|
||||
@IsEnum(PostVisibility)
|
||||
visibility?: PostVisibility;
|
||||
}
|
||||
11
src/modules/posts/dto/post-query.dto.ts
Normal file
11
src/modules/posts/dto/post-query.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
|
||||
|
||||
export class PostQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ enum: PostVisibility })
|
||||
@IsOptional()
|
||||
@IsEnum(PostVisibility)
|
||||
visibility?: PostVisibility;
|
||||
}
|
||||
26
src/modules/posts/dto/update-post.dto.ts
Normal file
26
src/modules/posts/dto/update-post.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
|
||||
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
|
||||
|
||||
export class UpdatePostDto {
|
||||
@ApiPropertyOptional({ maxLength: 2200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 2200)
|
||||
content?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Set video URL. If provided, audioUrl will be cleared.' })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
videoUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Set audio URL. If provided, videoUrl will be cleared.' })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
audioUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PostVisibility })
|
||||
@IsOptional()
|
||||
@IsEnum(PostVisibility)
|
||||
visibility?: PostVisibility;
|
||||
}
|
||||
55
src/modules/posts/posts.controller.ts
Normal file
55
src/modules/posts/posts.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, 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 { CreatePostDto } from './dto/create-post.dto';
|
||||
import { PostQueryDto } from './dto/post-query.dto';
|
||||
import { UpdatePostDto } from './dto/update-post.dto';
|
||||
import { PostsService } from './posts.service';
|
||||
|
||||
@ApiTags('Posts')
|
||||
@Controller('posts')
|
||||
export class PostsController {
|
||||
constructor(private readonly postsService: PostsService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreatePostDto) {
|
||||
return this.postsService.create(user.sub, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('user/:userId')
|
||||
async findUserPosts(@Param('userId') userId: string, @Query() query: PostQueryDto) {
|
||||
return this.postsService.findUserPosts(userId, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get(':postId')
|
||||
async findById(@Param('postId') postId: string) {
|
||||
return this.postsService.findById(postId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':postId')
|
||||
async update(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('postId') postId: string,
|
||||
@Body() dto: UpdatePostDto,
|
||||
) {
|
||||
return this.postsService.update(user.sub, postId, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':postId')
|
||||
async remove(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
|
||||
await this.postsService.remove(user.sub, postId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
لم تُعرض بعض الملفات لأن الكثير من الملفات تغيرت في هذا الاختلاف إظهار المزيد
المرجع في مشكلة جديدة
حظر مستخدم