569 أسطر
17 KiB
TypeScript
569 أسطر
17 KiB
TypeScript
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<CollaborationRequestDocument>,
|
|
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<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 })
|
|
.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<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>,
|
|
) {
|
|
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<CollaborationRequestStatus, 'approved' | 'rejected'>,
|
|
) {
|
|
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<string, unknown>;
|
|
}) {
|
|
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}`,
|
|
};
|
|
}
|
|
}
|