From ddac88582f94ad52fc2f619bb4dd485d432e73ef Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Tue, 9 Jun 2026 17:32:01 +0300 Subject: [PATCH] Improve collaboration requests APIs --- src/common/enums/notification-type.enum.ts | 3 + .../collaboration-requests.controller.ts | 24 ++ .../collaboration-requests.service.spec.ts | 188 ++++++++++++++++ .../collaboration-requests.service.ts | 213 +++++++++++++++--- .../dto/collaboration-request-query.dto.ts | 14 ++ .../schemas/collaboration-request.schema.ts | 18 +- .../notifications.service.spec.ts | 4 + .../notifications/notifications.service.ts | 18 +- src/modules/users/users.service.ts | 15 +- 9 files changed, 454 insertions(+), 43 deletions(-) create mode 100644 src/modules/collaboration-requests/collaboration-requests.service.spec.ts create mode 100644 src/modules/collaboration-requests/dto/collaboration-request-query.dto.ts diff --git a/src/common/enums/notification-type.enum.ts b/src/common/enums/notification-type.enum.ts index 70fc607..28a238d 100644 --- a/src/common/enums/notification-type.enum.ts +++ b/src/common/enums/notification-type.enum.ts @@ -9,4 +9,7 @@ export enum NotificationType { REPLY = 'reply', SYSTEM = 'system', COLLABORATION_REQUEST = 'collaboration_request', + COLLABORATION_REQUEST_APPROVED = 'collaboration_request_approved', + COLLABORATION_REQUEST_REJECTED = 'collaboration_request_rejected', + COLLABORATION_REQUEST_CANCELLED = 'collaboration_request_cancelled', } diff --git a/src/modules/collaboration-requests/collaboration-requests.controller.ts b/src/modules/collaboration-requests/collaboration-requests.controller.ts index fe46e0e..4928358 100644 --- a/src/modules/collaboration-requests/collaboration-requests.controller.ts +++ b/src/modules/collaboration-requests/collaboration-requests.controller.ts @@ -4,6 +4,7 @@ import { CurrentUser } from '../../common/decorators/current-user.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 { CollaborationRequestQueryDto } from './dto/collaboration-request-query.dto'; import { CollaborationRequestsService } from './collaboration-requests.service'; @ApiTags('Collaboration Requests') @@ -18,6 +19,24 @@ export class CollaborationRequestsController { return this.collaborationRequestsService.getMine(user.sub, query); } + @Get('received') + async received( + @CurrentUser() user: JwtPayload, + @Query() query: CollaborationRequestQueryDto, + ) { + return this.collaborationRequestsService.listReceived(user.sub, query); + } + + @Get('sent') + async sent(@CurrentUser() user: JwtPayload, @Query() query: CollaborationRequestQueryDto) { + return this.collaborationRequestsService.listSent(user.sub, query); + } + + @Get(':requestId') + async getById(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) { + return this.collaborationRequestsService.getById(user.sub, requestId); + } + @Patch(':requestId/approve') async approve(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) { return this.collaborationRequestsService.approve(user.sub, requestId); @@ -27,4 +46,9 @@ export class CollaborationRequestsController { async reject(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) { return this.collaborationRequestsService.reject(user.sub, requestId); } + + @Patch(':requestId/cancel') + async cancel(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) { + return this.collaborationRequestsService.cancel(user.sub, requestId); + } } diff --git a/src/modules/collaboration-requests/collaboration-requests.service.spec.ts b/src/modules/collaboration-requests/collaboration-requests.service.spec.ts new file mode 100644 index 0000000..cdb3b33 --- /dev/null +++ b/src/modules/collaboration-requests/collaboration-requests.service.spec.ts @@ -0,0 +1,188 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { Types } from 'mongoose'; +import { CollaborationRequestsService } from './collaboration-requests.service'; + +const chain = (value: T) => ({ + populate: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(value), +}); + +const createRequestDoc = (overrides: Record = {}) => { + const requestId = overrides._id ?? new Types.ObjectId(); + return { + _id: requestId, + id: requestId.toString(), + postId: overrides.postId ?? new Types.ObjectId(), + requesterId: overrides.requesterId ?? new Types.ObjectId(), + targetUserId: overrides.targetUserId ?? new Types.ObjectId(), + status: overrides.status ?? 'pending', + collaborationType: overrides.collaborationType ?? 'duet', + save: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +}; + +const createService = () => { + const model = { + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + find: jest.fn(), + countDocuments: jest.fn(), + }; + const postsRepository = { + findById: jest.fn(), + updateById: jest.fn(), + }; + const usersRepository = { + findById: jest.fn(), + }; + const blocksRepository = { + findAnyBetween: jest.fn(), + }; + const notificationsService = { + create: jest.fn(), + }; + + const service = new CollaborationRequestsService( + model as any, + postsRepository as any, + usersRepository as any, + blocksRepository as any, + notificationsService as any, + ); + + return { + service, + model, + postsRepository, + usersRepository, + blocksRepository, + notificationsService, + }; +}; + +describe('CollaborationRequestsService', () => { + it('creates a pending collaboration request and notifies the target user', async () => { + const requesterId = new Types.ObjectId().toString(); + const targetUserId = new Types.ObjectId().toString(); + const postId = new Types.ObjectId().toString(); + const request = createRequestDoc({ + postId: new Types.ObjectId(postId), + requesterId: new Types.ObjectId(requesterId), + targetUserId: new Types.ObjectId(targetUserId), + }); + const { service, model, postsRepository, usersRepository, blocksRepository, notificationsService } = + createService(); + + postsRepository.findById.mockResolvedValue({ authorId: new Types.ObjectId(requesterId) }); + usersRepository.findById.mockResolvedValue({ isDisabled: false }); + blocksRepository.findAnyBetween.mockResolvedValue(null); + model.findOne + .mockReturnValueOnce(chain(null)) + .mockReturnValueOnce(chain({ ...request, populated: true })); + model.findOneAndUpdate.mockReturnValue(chain(request)); + + await expect( + service.create(requesterId, postId, { + targetUserId, + collaborationType: 'duet', + message: 'Let us work', + }), + ).resolves.toMatchObject({ + message: 'Collaboration request sent', + request: expect.objectContaining({ populated: true }), + }); + + expect(notificationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: requesterId, + recipientId: targetUserId, + type: 'collaboration_request', + referenceId: postId, + deepLink: `/posts/${postId}`, + }), + ); + }); + + it('lists received requests with optional status filter', async () => { + const targetUserId = new Types.ObjectId().toString(); + const items = [createRequestDoc({ targetUserId: new Types.ObjectId(targetUserId) })]; + const { service, model } = createService(); + + model.find.mockReturnValue(chain(items)); + model.countDocuments.mockReturnValue(chain(1)); + + const result = await service.listReceived(targetUserId, { + page: 1, + limit: 20, + status: 'approved', + }); + + expect(result.items).toHaveLength(1); + expect(model.find).toHaveBeenCalledWith({ + targetUserId: new Types.ObjectId(targetUserId), + status: 'approved', + }); + }); + + it('approves a pending request, updates post collaborators, and notifies requester', async () => { + const targetUserId = new Types.ObjectId().toString(); + const requesterId = new Types.ObjectId().toString(); + const postId = new Types.ObjectId(); + const request = createRequestDoc({ + postId, + requesterId: new Types.ObjectId(requesterId), + targetUserId: new Types.ObjectId(targetUserId), + }); + const { service, model, postsRepository, notificationsService } = createService(); + + model.findById.mockReturnValue(chain(request)); + model.findOne.mockReturnValue(chain({ ...request, status: 'approved' })); + + await expect(service.approve(targetUserId, request.id)).resolves.toMatchObject({ + approved: true, + request: expect.objectContaining({ status: 'approved' }), + }); + + expect(postsRepository.updateById).toHaveBeenCalledWith(postId.toString(), { + $addToSet: { collaboratorIds: request.targetUserId }, + }); + expect(notificationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + recipientId: requesterId, + type: 'collaboration_request_approved', + }), + ); + }); + + it('allows only the requester to cancel a pending request', async () => { + const requesterId = new Types.ObjectId().toString(); + const otherUserId = new Types.ObjectId().toString(); + const request = createRequestDoc({ requesterId: new Types.ObjectId(requesterId) }); + const { service, model } = createService(); + + model.findById.mockReturnValue(chain(request)); + + await expect(service.cancel(otherUserId, request.id)).rejects.toBeInstanceOf(ForbiddenException); + expect(request.save).not.toHaveBeenCalled(); + }); + + it('rejects cancelling non-pending requests', async () => { + const requesterId = new Types.ObjectId().toString(); + const request = createRequestDoc({ + requesterId: new Types.ObjectId(requesterId), + status: 'approved', + }); + const { service, model } = createService(); + + model.findById.mockReturnValue(chain(request)); + + await expect(service.cancel(requesterId, request.id)).rejects.toBeInstanceOf(BadRequestException); + expect(request.save).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/collaboration-requests/collaboration-requests.service.ts b/src/modules/collaboration-requests/collaboration-requests.service.ts index 1cdaae7..945cd13 100644 --- a/src/modules/collaboration-requests/collaboration-requests.service.ts +++ b/src/modules/collaboration-requests/collaboration-requests.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; @@ -13,13 +14,18 @@ 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 { CollaborationRequestQueryDto } from './dto/collaboration-request-query.dto'; import { CollaborationRequest, CollaborationRequestDocument, + CollaborationRequestStatus, } from './schemas/collaboration-request.schema'; @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, @@ -81,35 +87,96 @@ export class CollaborationRequestsService { { new: true, upsert: true, setDefaultsOnInsert: true }, ) .exec(); + if (!request) { + throw new NotFoundException('Collaboration request not found'); + } if (!existing) { - await this.notificationsService.create({ + await this.createCollaborationNotification({ actorId: requesterId, recipientId: targetUserId, type: 'collaboration_request', - referenceId: postId, - resourceType: 'post', - deepLink: `/posts/${postId}`, + postId, + requestId: request.id, metadata: collaborationDetails, }); } - return { message: 'Collaboration request sent', request }; + 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', + postId: request.postId.toString(), + 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 = { targetUserId: new Types.ObjectId(targetUserId), status: 'pending' }; + const filter: Record = { + [field]: new Types.ObjectId(userId), + ...(query.status ? { status: query.status } : {}), + }; const [items, total] = await Promise.all([ - this.collaborationRequestModel - .find(filter) - .populate({ - path: 'requesterId', - select: 'name username stageName avatar isVerified isDisabled', - }) - .populate({ path: 'postId' }) + this.populateRequestQuery(this.collaborationRequestModel.find(filter)) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) @@ -124,12 +191,42 @@ export class CollaborationRequestsService { await this.postsRepository.updateById(request.postId.toString(), { $addToSet: { collaboratorIds: request.targetUserId }, }); - return { approved: true, request }; + await this.createCollaborationNotification({ + actorId: targetUserId, + recipientId: request.requesterId.toString(), + type: 'collaboration_request_approved', + postId: request.postId.toString(), + 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'); - return { rejected: true, request }; + await this.createCollaborationNotification({ + actorId: targetUserId, + recipientId: request.requesterId.toString(), + type: 'collaboration_request_rejected', + postId: request.postId.toString(), + requestId: request.id, + metadata: { + status: 'rejected', + collaborationType: request.collaborationType ?? null, + }, + }); + + return { + rejected: true, + request: await this.findVisibleRequestOrFail(targetUserId, request.id), + }; } private buildCollaborationDetails(dto: CreateCollaborationRequestDto) { @@ -157,25 +254,87 @@ export class CollaborationRequestsService { private async updateStatus( targetUserId: string, requestId: string, - status: 'approved' | 'rejected', + status: Extract, ) { if (!Types.ObjectId.isValid(requestId)) { throw new BadRequestException('Invalid collaboration request id'); } - const request = await this.collaborationRequestModel - .findOneAndUpdate( - { - _id: new Types.ObjectId(requestId), - targetUserId: new Types.ObjectId(targetUserId), - status: 'pending', - }, - { status }, - { new: true }, - ) - .exec(); + 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'; + postId: string; + requestId: string; + metadata?: Record; + }) { + try { + await this.notificationsService.create({ + actorId: options.actorId, + recipientId: options.recipientId, + type: options.type, + referenceId: options.postId, + resourceType: 'post', + deepLink: `/posts/${options.postId}`, + metadata: { + ...(options.metadata ?? {}), + collaborationRequestId: options.requestId, + }, + }); + } catch (error) { + this.logger.warn( + `Collaboration notification failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } } diff --git a/src/modules/collaboration-requests/dto/collaboration-request-query.dto.ts b/src/modules/collaboration-requests/dto/collaboration-request-query.dto.ts new file mode 100644 index 0000000..aaf390f --- /dev/null +++ b/src/modules/collaboration-requests/dto/collaboration-request-query.dto.ts @@ -0,0 +1,14 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsOptional } from 'class-validator'; +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; +import { + COLLABORATION_REQUEST_STATUSES, + CollaborationRequestStatus, +} from '../schemas/collaboration-request.schema'; + +export class CollaborationRequestQueryDto extends PaginationQueryDto { + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value)) + @IsEnum(COLLABORATION_REQUEST_STATUSES) + status?: CollaborationRequestStatus; +} diff --git a/src/modules/collaboration-requests/schemas/collaboration-request.schema.ts b/src/modules/collaboration-requests/schemas/collaboration-request.schema.ts index 6e666de..9dc2641 100644 --- a/src/modules/collaboration-requests/schemas/collaboration-request.schema.ts +++ b/src/modules/collaboration-requests/schemas/collaboration-request.schema.ts @@ -10,6 +10,14 @@ export type CollaborationType = (typeof COLLABORATION_TYPES)[number]; export const COLLABORATION_ATTACHMENT_TYPES = ['audio', 'demo', 'file'] as const; export type CollaborationAttachmentType = (typeof COLLABORATION_ATTACHMENT_TYPES)[number]; +export const COLLABORATION_REQUEST_STATUSES = [ + 'pending', + 'approved', + 'rejected', + 'cancelled', +] as const; +export type CollaborationRequestStatus = (typeof COLLABORATION_REQUEST_STATUSES)[number]; + @Schema({ timestamps: true, versionKey: false }) export class CollaborationRequest { @Prop({ type: Types.ObjectId, ref: Post.name, required: true, index: true }) @@ -21,8 +29,13 @@ export class CollaborationRequest { @Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true }) targetUserId!: Types.ObjectId; - @Prop({ type: String, enum: ['pending', 'approved', 'rejected'], default: 'pending', index: true }) - status!: 'pending' | 'approved' | 'rejected'; + @Prop({ + type: String, + enum: COLLABORATION_REQUEST_STATUSES, + default: 'pending', + index: true, + }) + status!: CollaborationRequestStatus; @Prop({ type: String, @@ -51,3 +64,4 @@ export class CollaborationRequest { export const CollaborationRequestSchema = SchemaFactory.createForClass(CollaborationRequest); CollaborationRequestSchema.index({ postId: 1, targetUserId: 1, status: 1 }); CollaborationRequestSchema.index({ targetUserId: 1, status: 1, createdAt: -1 }); +CollaborationRequestSchema.index({ requesterId: 1, status: 1, createdAt: -1 }); diff --git a/src/modules/notifications/notifications.service.spec.ts b/src/modules/notifications/notifications.service.spec.ts index 64c9372..0475bbe 100644 --- a/src/modules/notifications/notifications.service.spec.ts +++ b/src/modules/notifications/notifications.service.spec.ts @@ -175,6 +175,8 @@ describe('NotificationsService', () => { 'save', 'share', 'collaboration_request', + 'collaboration_request_approved', + 'collaboration_request_rejected', 'system', ], }, @@ -295,6 +297,8 @@ describe('NotificationsService', () => { 'save', 'share', 'collaboration_request', + 'collaboration_request_approved', + 'collaboration_request_rejected', 'system', ], }, diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts index 004fa53..0cef007 100644 --- a/src/modules/notifications/notifications.service.ts +++ b/src/modules/notifications/notifications.service.ts @@ -17,12 +17,19 @@ const NOTIFICATION_CATEGORY_TYPES: Record