Support general collaboration requests with audio attachments

هذا الالتزام موجود في:
boutmoun123
2026-06-10 17:34:34 +03:00
الأصل bd2e676e89
التزام 48ea432669
7 ملفات معدلة مع 516 إضافات و23 حذوفات

عرض الملف

@@ -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<string, unknown> = {
[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<string> {
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<string, unknown>,
@@ -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<string, unknown>;
}) {
@@ -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}`,
};
}
}