From 48ea432669d34737caba2c00c66c6f9bffc328b7 Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Wed, 10 Jun 2026 17:34:34 +0300 Subject: [PATCH] Support general collaboration requests with audio attachments --- .../collaboration-requests.module.ts | 7 +- .../collaboration-requests.service.spec.ts | 154 ++++++++++- .../collaboration-requests.service.ts | 261 +++++++++++++++++- ...reate-general-collaboration-request.dto.ts | 32 +++ ...posts-collaboration-requests.controller.ts | 27 +- .../schemas/collaboration-request.schema.ts | 10 +- ...users-collaboration-requests.controller.ts | 48 ++++ 7 files changed, 516 insertions(+), 23 deletions(-) create mode 100644 src/modules/collaboration-requests/dto/create-general-collaboration-request.dto.ts create mode 100644 src/modules/collaboration-requests/users-collaboration-requests.controller.ts diff --git a/src/modules/collaboration-requests/collaboration-requests.module.ts b/src/modules/collaboration-requests/collaboration-requests.module.ts index f296ca4..5170bef 100644 --- a/src/modules/collaboration-requests/collaboration-requests.module.ts +++ b/src/modules/collaboration-requests/collaboration-requests.module.ts @@ -7,6 +7,7 @@ import { UsersModule } from '../users/users.module'; import { CollaborationRequestsController } from './collaboration-requests.controller'; import { CollaborationRequestsService } from './collaboration-requests.service'; import { PostsCollaborationRequestsController } from './posts-collaboration-requests.controller'; +import { UsersCollaborationRequestsController } from './users-collaboration-requests.controller'; import { CollaborationRequest, CollaborationRequestSchema, @@ -20,7 +21,11 @@ import { UsersModule, MongooseModule.forFeature([{ name: CollaborationRequest.name, schema: CollaborationRequestSchema }]), ], - controllers: [CollaborationRequestsController, PostsCollaborationRequestsController], + controllers: [ + CollaborationRequestsController, + PostsCollaborationRequestsController, + UsersCollaborationRequestsController, + ], providers: [CollaborationRequestsService], }) export class CollaborationRequestsModule {} diff --git a/src/modules/collaboration-requests/collaboration-requests.service.spec.ts b/src/modules/collaboration-requests/collaboration-requests.service.spec.ts index cdb3b33..36daa6e 100644 --- a/src/modules/collaboration-requests/collaboration-requests.service.spec.ts +++ b/src/modules/collaboration-requests/collaboration-requests.service.spec.ts @@ -15,7 +15,9 @@ const createRequestDoc = (overrides: Record = {}) => { return { _id: requestId, id: requestId.toString(), - postId: overrides.postId ?? new Types.ObjectId(), + postId: Object.prototype.hasOwnProperty.call(overrides, 'postId') + ? overrides.postId + : new Types.ObjectId(), requesterId: overrides.requesterId ?? new Types.ObjectId(), targetUserId: overrides.targetUserId ?? new Types.ObjectId(), status: overrides.status ?? 'pending', @@ -109,6 +111,126 @@ describe('CollaborationRequestsService', () => { ); }); + it('creates a general collaboration request from a user profile', async () => { + const requesterId = new Types.ObjectId().toString(); + const targetUserId = new Types.ObjectId().toString(); + const request = createRequestDoc({ + postId: null, + requesterId: new Types.ObjectId(requesterId), + targetUserId: new Types.ObjectId(targetUserId), + }); + const { service, model, usersRepository, blocksRepository, notificationsService } = + createService(); + + 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.createGeneral(requesterId, targetUserId, { + collaborationType: 'duet', + message: 'Let us work without a post', + attachmentType: 'audio', + }), + ).resolves.toMatchObject({ + message: 'Collaboration request sent', + request: expect.objectContaining({ populated: true }), + }); + + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + postId: null, + requesterId: new Types.ObjectId(requesterId), + targetUserId: new Types.ObjectId(targetUserId), + status: 'pending', + }), + expect.objectContaining({ + $setOnInsert: expect.objectContaining({ postId: null }), + }), + expect.any(Object), + ); + expect(notificationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: requesterId, + recipientId: targetUserId, + type: 'collaboration_request', + resourceType: 'user', + referenceId: requesterId, + deepLink: `/users/${requesterId}`, + }), + ); + }); + + it('updates duplicate pending general requests instead of creating duplicates', async () => { + const requesterId = new Types.ObjectId().toString(); + const targetUserId = new Types.ObjectId().toString(); + const existing = createRequestDoc({ + postId: null, + requesterId: new Types.ObjectId(requesterId), + targetUserId: new Types.ObjectId(targetUserId), + }); + const updated = { ...existing, message: 'Updated profile collaboration' }; + const { service, model, usersRepository, blocksRepository, notificationsService } = + createService(); + + usersRepository.findById.mockResolvedValue({ isDisabled: false }); + blocksRepository.findAnyBetween.mockResolvedValue(null); + model.findOne + .mockReturnValueOnce(chain(existing)) + .mockReturnValueOnce(chain({ ...updated, populated: true })); + model.findByIdAndUpdate.mockReturnValue(chain(updated)); + + await service.createGeneral(requesterId, targetUserId, { + message: 'Updated profile collaboration', + }); + + expect(model.findByIdAndUpdate).toHaveBeenCalledWith( + existing.id, + { $set: { message: 'Updated profile collaboration' } }, + { new: true }, + ); + expect(model.findOneAndUpdate).not.toHaveBeenCalled(); + expect(notificationsService.create).not.toHaveBeenCalled(); + }); + + it('prevents self general collaboration requests', async () => { + const requesterId = new Types.ObjectId().toString(); + const { service } = createService(); + + await expect(service.createGeneral(requesterId, requesterId, {})).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('prevents general requests to disabled target users', async () => { + const requesterId = new Types.ObjectId().toString(); + const targetUserId = new Types.ObjectId().toString(); + const { service, usersRepository, blocksRepository } = createService(); + + usersRepository.findById.mockResolvedValue({ isDisabled: true }); + blocksRepository.findAnyBetween.mockResolvedValue(null); + + await expect(service.createGeneral(requesterId, targetUserId, {})).rejects.toThrow( + 'Target user not found', + ); + }); + + it('respects blocks for general collaboration requests', async () => { + const requesterId = new Types.ObjectId().toString(); + const targetUserId = new Types.ObjectId().toString(); + const { service, usersRepository, blocksRepository } = createService(); + + usersRepository.findById.mockResolvedValue({ isDisabled: false }); + blocksRepository.findAnyBetween.mockResolvedValue({ _id: new Types.ObjectId() }); + + await expect(service.createGeneral(requesterId, targetUserId, {})).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + it('lists received requests with optional status filter', async () => { const targetUserId = new Types.ObjectId().toString(); const items = [createRequestDoc({ targetUserId: new Types.ObjectId(targetUserId) })]; @@ -160,6 +282,36 @@ describe('CollaborationRequestsService', () => { ); }); + it('approves a general request without updating post collaborators', async () => { + const targetUserId = new Types.ObjectId().toString(); + const requesterId = new Types.ObjectId().toString(); + const request = createRequestDoc({ + postId: null, + 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).not.toHaveBeenCalled(); + expect(notificationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + recipientId: requesterId, + type: 'collaboration_request_approved', + resourceType: 'user', + referenceId: targetUserId, + deepLink: `/users/${targetUserId}`, + }), + ); + }); + it('allows only the requester to cancel a pending request', async () => { const requesterId = new Types.ObjectId().toString(); const otherUserId = new Types.ObjectId().toString(); diff --git a/src/modules/collaboration-requests/collaboration-requests.service.ts b/src/modules/collaboration-requests/collaboration-requests.service.ts index 945cd13..df0bf7d 100644 --- a/src/modules/collaboration-requests/collaboration-requests.service.ts +++ b/src/modules/collaboration-requests/collaboration-requests.service.ts @@ -6,7 +6,10 @@ import { 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'; @@ -14,6 +17,7 @@ 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, @@ -21,6 +25,13 @@ import { 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); @@ -35,11 +46,18 @@ export class CollaborationRequestsService { private readonly notificationsService: NotificationsService, ) {} - async create(requesterId: string, postId: string, dto: CreateCollaborationRequestDto) { + async create( + requesterId: string, + postId: string, + dto: CreateCollaborationRequestDto, + file?: UploadedAudioFile, + ) { const targetUserId = dto.targetUserId; + if (!Types.ObjectId.isValid(postId) || !Types.ObjectId.isValid(targetUserId)) { throw new BadRequestException('Invalid collaboration request'); } + if (requesterId === targetUserId) { throw new BadRequestException('You cannot invite yourself'); } @@ -49,15 +67,19 @@ export class CollaborationRequestsService { this.usersRepository.findById(targetUserId), this.blocksRepository.findAnyBetween(requesterId, targetUserId), ]); + if (!post) { throw new NotFoundException('Post not found'); } + if (post.authorId.toString() !== requesterId) { throw new ForbiddenException('Only the post owner can invite collaborators'); } + if (!targetUser || targetUser.isDisabled) { throw new NotFoundException('Target user not found'); } + if (block) { throw new BadRequestException('You cannot invite this user'); } @@ -68,8 +90,10 @@ export class CollaborationRequestsService { targetUserId: new Types.ObjectId(targetUserId), status: 'pending', }; - const collaborationDetails = this.buildCollaborationDetails(dto); + + 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 @@ -87,6 +111,7 @@ export class CollaborationRequestsService { { new: true, upsert: true, setDefaultsOnInsert: true }, ) .exec(); + if (!request) { throw new NotFoundException('Collaboration request not found'); } @@ -96,7 +121,87 @@ export class CollaborationRequestsService { actorId: requesterId, recipientId: targetUserId, type: 'collaboration_request', - postId, + 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, }); @@ -132,12 +237,15 @@ export class CollaborationRequestsService { } 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'); } @@ -149,7 +257,7 @@ export class CollaborationRequestsService { actorId: requesterId, recipientId: request.targetUserId.toString(), type: 'collaboration_request_cancelled', - postId: request.postId.toString(), + ...this.buildNotificationTargetForRequest(request, requesterId), requestId: request.id, metadata: { status: 'cancelled', @@ -171,10 +279,12 @@ export class CollaborationRequestsService { 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 }) @@ -183,19 +293,24 @@ export class CollaborationRequestsService { .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'); - await this.postsRepository.updateById(request.postId.toString(), { - $addToSet: { collaboratorIds: request.targetUserId }, - }); + + if (request.postId) { + await this.postsRepository.updateById(request.postId.toString(), { + $addToSet: { collaboratorIds: request.targetUserId }, + }); + } + await this.createCollaborationNotification({ actorId: targetUserId, recipientId: request.requesterId.toString(), type: 'collaboration_request_approved', - postId: request.postId.toString(), + ...this.buildNotificationTargetForRequest(request, targetUserId), requestId: request.id, metadata: { status: 'approved', @@ -211,11 +326,12 @@ export class CollaborationRequestsService { 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', - postId: request.postId.toString(), + ...this.buildNotificationTargetForRequest(request, targetUserId), requestId: request.id, metadata: { status: 'rejected', @@ -229,7 +345,9 @@ export class CollaborationRequestsService { }; } - private buildCollaborationDetails(dto: CreateCollaborationRequestDto) { + private buildCollaborationDetails( + dto: CreateCollaborationRequestDto | CreateGeneralCollaborationRequestDto, + ) { return { ...(dto.collaborationType ? { collaborationType: dto.collaborationType } : {}), ...(typeof dto.message === 'string' ? { message: dto.message.trim() } : {}), @@ -238,6 +356,91 @@ export class CollaborationRequestsService { }; } + 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, @@ -259,19 +462,24 @@ export class CollaborationRequestsService { 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; } @@ -312,7 +520,9 @@ export class CollaborationRequestsService { | 'collaboration_request_approved' | 'collaboration_request_rejected' | 'collaboration_request_cancelled'; - postId: string; + referenceId: string; + resourceType: string; + deepLink: string; requestId: string; metadata?: Record; }) { @@ -321,9 +531,9 @@ export class CollaborationRequestsService { actorId: options.actorId, recipientId: options.recipientId, type: options.type, - referenceId: options.postId, - resourceType: 'post', - deepLink: `/posts/${options.postId}`, + referenceId: options.referenceId, + resourceType: options.resourceType, + deepLink: options.deepLink, metadata: { ...(options.metadata ?? {}), collaborationRequestId: options.requestId, @@ -337,4 +547,25 @@ export class CollaborationRequestsService { ); } } -} + + 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}`, + }; + } +} \ No newline at end of file diff --git a/src/modules/collaboration-requests/dto/create-general-collaboration-request.dto.ts b/src/modules/collaboration-requests/dto/create-general-collaboration-request.dto.ts new file mode 100644 index 0000000..78ee1a3 --- /dev/null +++ b/src/modules/collaboration-requests/dto/create-general-collaboration-request.dto.ts @@ -0,0 +1,32 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + COLLABORATION_ATTACHMENT_TYPES, + COLLABORATION_TYPES, + CollaborationAttachmentType, + CollaborationType, +} from '../schemas/collaboration-request.schema'; + +export class CreateGeneralCollaborationRequestDto { + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value)) + @IsEnum(COLLABORATION_TYPES) + collaborationType?: CollaborationType; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MaxLength(1000) + message?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MaxLength(500) + attachmentUrl?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value)) + @IsEnum(COLLABORATION_ATTACHMENT_TYPES) + attachmentType?: CollaborationAttachmentType; +} diff --git a/src/modules/collaboration-requests/posts-collaboration-requests.controller.ts b/src/modules/collaboration-requests/posts-collaboration-requests.controller.ts index 300d473..1e1fc53 100644 --- a/src/modules/collaboration-requests/posts-collaboration-requests.controller.ts +++ b/src/modules/collaboration-requests/posts-collaboration-requests.controller.ts @@ -1,11 +1,27 @@ -import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Param, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, 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 { CollaborationRequestsService } from './collaboration-requests.service'; import { CreateCollaborationRequestDto } from './dto/create-collaboration-request.dto'; +type UploadedAudioFile = { + buffer: Buffer; + originalname: string; + mimetype: string; + size: number; +}; + @ApiTags('Post Collaboration Requests') @ApiBearerAuth() @UseGuards(JwtAuthGuard) @@ -14,11 +30,14 @@ export class PostsCollaborationRequestsController { constructor(private readonly collaborationRequestsService: CollaborationRequestsService) {} @Post(':postId/collaboration-requests') + @ApiConsumes('multipart/form-data') + @UseInterceptors(FileInterceptor('attachmentUrl')) async create( @CurrentUser() user: JwtPayload, @Param('postId') postId: string, @Body() dto: CreateCollaborationRequestDto, + @UploadedFile() attachmentUrl?: UploadedAudioFile, ) { - return this.collaborationRequestsService.create(user.sub, postId, dto); + return this.collaborationRequestsService.create(user.sub, postId, dto, attachmentUrl); } -} +} \ No newline at end of file diff --git a/src/modules/collaboration-requests/schemas/collaboration-request.schema.ts b/src/modules/collaboration-requests/schemas/collaboration-request.schema.ts index 9dc2641..14d1ee9 100644 --- a/src/modules/collaboration-requests/schemas/collaboration-request.schema.ts +++ b/src/modules/collaboration-requests/schemas/collaboration-request.schema.ts @@ -20,8 +20,14 @@ export type CollaborationRequestStatus = (typeof COLLABORATION_REQUEST_STATUSES) @Schema({ timestamps: true, versionKey: false }) export class CollaborationRequest { - @Prop({ type: Types.ObjectId, ref: Post.name, required: true, index: true }) - postId!: Types.ObjectId; + @Prop({ + type: Types.ObjectId, + ref: Post.name, + required: false, + default: null, + index: true, + }) + postId?: Types.ObjectId | null; @Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true }) requesterId!: Types.ObjectId; diff --git a/src/modules/collaboration-requests/users-collaboration-requests.controller.ts b/src/modules/collaboration-requests/users-collaboration-requests.controller.ts new file mode 100644 index 0000000..90337e4 --- /dev/null +++ b/src/modules/collaboration-requests/users-collaboration-requests.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + Param, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, 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 { CollaborationRequestsService } from './collaboration-requests.service'; +import { CreateGeneralCollaborationRequestDto } from './dto/create-general-collaboration-request.dto'; + +type UploadedAudioFile = { + buffer: Buffer; + originalname: string; + mimetype: string; + size: number; +}; + +@ApiTags('User Collaboration Requests') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('users') +export class UsersCollaborationRequestsController { + constructor(private readonly collaborationRequestsService: CollaborationRequestsService) {} + + @Post(':targetUserId/collaboration-requests') + @ApiConsumes('multipart/form-data') + @UseInterceptors(FileInterceptor('attachmentUrl')) + async createGeneral( + @CurrentUser() user: JwtPayload, + @Param('targetUserId') targetUserId: string, + @Body() dto: CreateGeneralCollaborationRequestDto, + @UploadedFile() attachmentUrl?: UploadedAudioFile, + ) { + return this.collaborationRequestsService.createGeneral( + user.sub, + targetUserId, + dto, + attachmentUrl, + ); + } +} \ No newline at end of file