Support general collaboration requests with audio attachments
هذا الالتزام موجود في:
@@ -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 {}
|
||||
|
||||
@@ -15,7 +15,9 @@ const createRequestDoc = (overrides: Record<string, any> = {}) => {
|
||||
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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم