import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { randomUUID } from 'crypto'; import { mkdir, writeFile } from 'fs/promises'; import { Model, Types } from 'mongoose'; import { extname, join } from 'path'; import { PaginationQueryDto } from '../../common/dto/pagination-query.dto'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { BlocksRepository } from '../blocks/blocks.repository'; import { NotificationsService } from '../notifications/notifications.service'; import { PostsRepository } from '../posts/posts.repository'; import { UsersRepository } from '../users/users.repository'; import { CreateCollaborationRequestDto } from './dto/create-collaboration-request.dto'; import { CreateGeneralCollaborationRequestDto } from './dto/create-general-collaboration-request.dto'; import { CollaborationRequestQueryDto } from './dto/collaboration-request-query.dto'; import { CollaborationRequest, CollaborationRequestDocument, CollaborationRequestStatus, } from './schemas/collaboration-request.schema'; type UploadedAudioFile = { buffer: Buffer; originalname: string; mimetype: string; size: number; }; @Injectable() export class CollaborationRequestsService { private readonly logger = new Logger(CollaborationRequestsService.name); private readonly userSelect = 'name username stageName avatar isVerified isDisabled'; constructor( @InjectModel(CollaborationRequest.name) private readonly collaborationRequestModel: Model, private readonly postsRepository: PostsRepository, private readonly usersRepository: UsersRepository, private readonly blocksRepository: BlocksRepository, private readonly notificationsService: NotificationsService, ) {} async create( requesterId: string, postId: string, dto: CreateCollaborationRequestDto, file?: UploadedAudioFile, ) { if (!Types.ObjectId.isValid(postId)) { throw new BadRequestException('Invalid collaboration request'); } const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } const targetUserId = post.authorId.toString(); if (requesterId === targetUserId) { throw new BadRequestException('You cannot request collaboration on your own post'); } const [targetUser, block] = await Promise.all([ this.usersRepository.findById(targetUserId), this.blocksRepository.findAnyBetween(requesterId, targetUserId), ]); if (!targetUser || targetUser.isDisabled) { throw new NotFoundException('Target user not found'); } if (block) { throw new BadRequestException('You cannot invite this user'); } const filter = { postId: new Types.ObjectId(postId), requesterId: new Types.ObjectId(requesterId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending', }; const collaborationDetails = await this.buildCollaborationDetailsWithFile(dto, file); const existing = await this.collaborationRequestModel.findOne(filter).exec(); const request = existing ? await this.updateExistingPendingRequest(existing.id, collaborationDetails) : await this.collaborationRequestModel .findOneAndUpdate( filter, { $setOnInsert: { postId: new Types.ObjectId(postId), requesterId: new Types.ObjectId(requesterId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending', ...collaborationDetails, }, }, { new: true, upsert: true, setDefaultsOnInsert: true }, ) .exec(); if (!request) { throw new NotFoundException('Collaboration request not found'); } if (!existing) { await this.createCollaborationNotification({ actorId: requesterId, recipientId: targetUserId, type: 'collaboration_request', referenceId: postId, resourceType: 'post', deepLink: `/posts/${postId}`, requestId: request.id, metadata: collaborationDetails, }); } return { message: 'Collaboration request sent', request: await this.findVisibleRequestOrFail(requesterId, request.id), }; } async createGeneral( requesterId: string, targetUserId: string, dto: CreateGeneralCollaborationRequestDto, file?: UploadedAudioFile, ) { if (!Types.ObjectId.isValid(targetUserId)) { throw new BadRequestException('Invalid collaboration request'); } if (requesterId === targetUserId) { throw new BadRequestException('You cannot invite yourself'); } const [targetUser, block] = await Promise.all([ this.usersRepository.findById(targetUserId), this.blocksRepository.findAnyBetween(requesterId, targetUserId), ]); if (!targetUser || targetUser.isDisabled) { throw new NotFoundException('Target user not found'); } if (block) { throw new BadRequestException('You cannot invite this user'); } const filter = { postId: null, requesterId: new Types.ObjectId(requesterId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending', }; const collaborationDetails = await this.buildCollaborationDetailsWithFile(dto, file); const existing = await this.collaborationRequestModel.findOne(filter).exec(); const request = existing ? await this.updateExistingPendingRequest(existing.id, collaborationDetails) : await this.collaborationRequestModel .findOneAndUpdate( filter, { $setOnInsert: { postId: null, requesterId: new Types.ObjectId(requesterId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending', ...collaborationDetails, }, }, { new: true, upsert: true, setDefaultsOnInsert: true }, ) .exec(); if (!request) { throw new NotFoundException('Collaboration request not found'); } if (!existing) { await this.createCollaborationNotification({ actorId: requesterId, recipientId: targetUserId, type: 'collaboration_request', referenceId: requesterId, resourceType: 'user', deepLink: `/users/${requesterId}`, requestId: request.id, metadata: collaborationDetails, }); } return { message: 'Collaboration request sent', request: await this.findVisibleRequestOrFail(requesterId, request.id), }; } async getMine(targetUserId: string, query: PaginationQueryDto) { return this.listReceived(targetUserId, { ...query, status: 'pending' }); } async listReceived(targetUserId: string, query: CollaborationRequestQueryDto) { return this.listForUser('targetUserId', targetUserId, query); } async listSent(requesterId: string, query: CollaborationRequestQueryDto) { return this.listForUser('requesterId', requesterId, query); } async getById(currentUserId: string, requestId: string) { return { request: await this.findVisibleRequestOrFail(currentUserId, requestId), }; } async cancel(requesterId: string, requestId: string) { if (!Types.ObjectId.isValid(requestId)) { throw new BadRequestException('Invalid collaboration request id'); } const request = await this.collaborationRequestModel.findById(requestId).exec(); if (!request) { throw new NotFoundException('Collaboration request not found'); } if (request.requesterId.toString() !== requesterId) { throw new ForbiddenException('Only the requester can cancel this collaboration request'); } if (request.status !== 'pending') { throw new BadRequestException('Only pending collaboration requests can be cancelled'); } request.status = 'cancelled'; await request.save(); await this.createCollaborationNotification({ actorId: requesterId, recipientId: request.targetUserId.toString(), type: 'collaboration_request_cancelled', ...this.buildNotificationTargetForRequest(request, requesterId), requestId: request.id, metadata: { status: 'cancelled', collaborationType: request.collaborationType ?? null, }, }); return { cancelled: true, request: await this.findVisibleRequestOrFail(requesterId, request.id), }; } private async listForUser( field: 'targetUserId' | 'requesterId', userId: string, query: CollaborationRequestQueryDto, ) { const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; const filter: Record = { [field]: new Types.ObjectId(userId), ...(query.status ? { status: query.status } : {}), }; const [items, total] = await Promise.all([ this.populateRequestQuery(this.collaborationRequestModel.find(filter)) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) .exec(), this.collaborationRequestModel.countDocuments(filter).exec(), ]); return buildPaginatedResponse(items, { page, limit, total, offset: skip }); } async approve(targetUserId: string, requestId: string) { const request = await this.updateStatus(targetUserId, requestId, 'approved'); if (request.postId) { await this.postsRepository.updateById(request.postId.toString(), { $addToSet: { collaboratorIds: request.requesterId }, }); } await this.createCollaborationNotification({ actorId: targetUserId, recipientId: request.requesterId.toString(), type: 'collaboration_request_approved', ...this.buildNotificationTargetForRequest(request, targetUserId), requestId: request.id, metadata: { status: 'approved', collaborationType: request.collaborationType ?? null, }, }); return { approved: true, request: await this.findVisibleRequestOrFail(targetUserId, request.id), }; } async reject(targetUserId: string, requestId: string) { const request = await this.updateStatus(targetUserId, requestId, 'rejected'); await this.createCollaborationNotification({ actorId: targetUserId, recipientId: request.requesterId.toString(), type: 'collaboration_request_rejected', ...this.buildNotificationTargetForRequest(request, targetUserId), requestId: request.id, metadata: { status: 'rejected', collaborationType: request.collaborationType ?? null, }, }); return { rejected: true, request: await this.findVisibleRequestOrFail(targetUserId, request.id), }; } private buildCollaborationDetails( dto: CreateCollaborationRequestDto | CreateGeneralCollaborationRequestDto, ) { return { ...(dto.collaborationType ? { collaborationType: dto.collaborationType } : {}), ...(typeof dto.message === 'string' ? { message: dto.message.trim() } : {}), ...(typeof dto.attachmentUrl === 'string' ? { attachmentUrl: dto.attachmentUrl.trim() } : {}), ...(dto.attachmentType ? { attachmentType: dto.attachmentType } : {}), }; } private async buildCollaborationDetailsWithFile( dto: CreateCollaborationRequestDto | CreateGeneralCollaborationRequestDto, file?: UploadedAudioFile, ) { const collaborationDetails = this.buildCollaborationDetails(dto); if (!file) { return collaborationDetails; } const attachmentUrl = await this.saveCollaborationAttachment(file); return { ...collaborationDetails, attachmentUrl, attachmentType: dto.attachmentType ?? 'audio', }; } private async saveCollaborationAttachment(file: UploadedAudioFile): Promise { const allowedMimeTypes = new Set([ 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/aac', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'application/octet-stream', ]); const originalExtension = extname(file.originalname || '').toLowerCase(); const allowedExtensions = new Set(['.mp3', '.wav', '.m4a', '.aac']); const isAllowedMime = allowedMimeTypes.has(file.mimetype); const isAllowedExtension = allowedExtensions.has(originalExtension); if (!isAllowedMime && !isAllowedExtension) { throw new BadRequestException('Only audio files are allowed for collaboration attachments'); } const maxSizeBytes = 20 * 1024 * 1024; if (file.size > maxSizeBytes) { throw new BadRequestException('Audio attachment must be less than 20MB'); } if (!file.buffer || !file.buffer.length) { throw new BadRequestException('Invalid audio attachment'); } const uploadDir = join(process.cwd(), 'uploads', 'collaboration-requests', 'audio'); await mkdir(uploadDir, { recursive: true }); const extension = originalExtension || this.extensionFromMimeType(file.mimetype); const filename = `collaboration-${randomUUID()}${extension}`; const filePath = join(uploadDir, filename); await writeFile(filePath, file.buffer); return `/uploads/collaboration-requests/audio/${filename}`; } private extensionFromMimeType(mimeType: string): string { switch (mimeType) { case 'audio/mpeg': case 'audio/mp3': return '.mp3'; case 'audio/wav': case 'audio/x-wav': case 'audio/wave': return '.wav'; case 'audio/aac': return '.aac'; case 'audio/mp4': case 'audio/m4a': case 'audio/x-m4a': return '.m4a'; default: return '.mp3'; } } private async updateExistingPendingRequest( requestId: string, collaborationDetails: Record, ) { if (!Object.keys(collaborationDetails).length) { return this.collaborationRequestModel.findById(requestId).exec(); } return this.collaborationRequestModel .findByIdAndUpdate(requestId, { $set: collaborationDetails }, { new: true }) .exec(); } private async updateStatus( targetUserId: string, requestId: string, status: Extract, ) { if (!Types.ObjectId.isValid(requestId)) { throw new BadRequestException('Invalid collaboration request id'); } const request = await this.collaborationRequestModel.findById(requestId).exec(); if (!request) { throw new NotFoundException('Collaboration request not found'); } if (request.targetUserId.toString() !== targetUserId) { throw new ForbiddenException('Only the target user can update this collaboration request'); } if (request.status !== 'pending') { throw new BadRequestException('Only pending collaboration requests can be updated'); } request.status = status; await request.save(); return request; } private populateRequestQuery(query: any) { return query .populate({ path: 'requesterId', select: this.userSelect }) .populate({ path: 'targetUserId', select: this.userSelect }) .populate({ path: 'postId' }); } private async findVisibleRequestOrFail(currentUserId: string, requestId: string) { if (!Types.ObjectId.isValid(requestId)) { throw new BadRequestException('Invalid collaboration request id'); } const request = await this.populateRequestQuery( this.collaborationRequestModel.findOne({ _id: new Types.ObjectId(requestId), $or: [ { requesterId: new Types.ObjectId(currentUserId) }, { targetUserId: new Types.ObjectId(currentUserId) }, ], }), ).exec(); if (!request) { throw new NotFoundException('Collaboration request not found'); } return request; } private async createCollaborationNotification(options: { actorId: string; recipientId: string; type: | 'collaboration_request' | 'collaboration_request_approved' | 'collaboration_request_rejected' | 'collaboration_request_cancelled'; referenceId: string; resourceType: string; deepLink: string; requestId: string; metadata?: Record; }) { try { await this.notificationsService.create({ actorId: options.actorId, recipientId: options.recipientId, type: options.type, referenceId: options.referenceId, resourceType: options.resourceType, deepLink: options.deepLink, metadata: { ...(options.metadata ?? {}), collaborationRequestId: options.requestId, }, }); } catch (error) { this.logger.warn( `Collaboration notification failed: ${ error instanceof Error ? error.message : String(error) }`, ); } } private buildNotificationTargetForRequest( request: CollaborationRequestDocument, actorId: string, ) { if (request.postId) { const postId = request.postId.toString(); return { referenceId: postId, resourceType: 'post', deepLink: `/posts/${postId}`, }; } return { referenceId: actorId, resourceType: 'user', deepLink: `/users/${actorId}`, }; } }