Support general collaboration requests with audio attachments

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

عرض الملف

@@ -7,6 +7,7 @@ import { UsersModule } from '../users/users.module';
import { CollaborationRequestsController } from './collaboration-requests.controller'; import { CollaborationRequestsController } from './collaboration-requests.controller';
import { CollaborationRequestsService } from './collaboration-requests.service'; import { CollaborationRequestsService } from './collaboration-requests.service';
import { PostsCollaborationRequestsController } from './posts-collaboration-requests.controller'; import { PostsCollaborationRequestsController } from './posts-collaboration-requests.controller';
import { UsersCollaborationRequestsController } from './users-collaboration-requests.controller';
import { import {
CollaborationRequest, CollaborationRequest,
CollaborationRequestSchema, CollaborationRequestSchema,
@@ -20,7 +21,11 @@ import {
UsersModule, UsersModule,
MongooseModule.forFeature([{ name: CollaborationRequest.name, schema: CollaborationRequestSchema }]), MongooseModule.forFeature([{ name: CollaborationRequest.name, schema: CollaborationRequestSchema }]),
], ],
controllers: [CollaborationRequestsController, PostsCollaborationRequestsController], controllers: [
CollaborationRequestsController,
PostsCollaborationRequestsController,
UsersCollaborationRequestsController,
],
providers: [CollaborationRequestsService], providers: [CollaborationRequestsService],
}) })
export class CollaborationRequestsModule {} export class CollaborationRequestsModule {}

عرض الملف

@@ -15,7 +15,9 @@ const createRequestDoc = (overrides: Record<string, any> = {}) => {
return { return {
_id: requestId, _id: requestId,
id: requestId.toString(), 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(), requesterId: overrides.requesterId ?? new Types.ObjectId(),
targetUserId: overrides.targetUserId ?? new Types.ObjectId(), targetUserId: overrides.targetUserId ?? new Types.ObjectId(),
status: overrides.status ?? 'pending', 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 () => { it('lists received requests with optional status filter', async () => {
const targetUserId = new Types.ObjectId().toString(); const targetUserId = new Types.ObjectId().toString();
const items = [createRequestDoc({ targetUserId: new Types.ObjectId(targetUserId) })]; 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 () => { it('allows only the requester to cancel a pending request', async () => {
const requesterId = new Types.ObjectId().toString(); const requesterId = new Types.ObjectId().toString();
const otherUserId = new Types.ObjectId().toString(); const otherUserId = new Types.ObjectId().toString();

عرض الملف

@@ -6,7 +6,10 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import { Model, Types } from 'mongoose'; import { Model, Types } from 'mongoose';
import { extname, join } from 'path';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto'; import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { BlocksRepository } from '../blocks/blocks.repository'; import { BlocksRepository } from '../blocks/blocks.repository';
@@ -14,6 +17,7 @@ import { NotificationsService } from '../notifications/notifications.service';
import { PostsRepository } from '../posts/posts.repository'; import { PostsRepository } from '../posts/posts.repository';
import { UsersRepository } from '../users/users.repository'; import { UsersRepository } from '../users/users.repository';
import { CreateCollaborationRequestDto } from './dto/create-collaboration-request.dto'; 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 { CollaborationRequestQueryDto } from './dto/collaboration-request-query.dto';
import { import {
CollaborationRequest, CollaborationRequest,
@@ -21,6 +25,13 @@ import {
CollaborationRequestStatus, CollaborationRequestStatus,
} from './schemas/collaboration-request.schema'; } from './schemas/collaboration-request.schema';
type UploadedAudioFile = {
buffer: Buffer;
originalname: string;
mimetype: string;
size: number;
};
@Injectable() @Injectable()
export class CollaborationRequestsService { export class CollaborationRequestsService {
private readonly logger = new Logger(CollaborationRequestsService.name); private readonly logger = new Logger(CollaborationRequestsService.name);
@@ -35,11 +46,18 @@ export class CollaborationRequestsService {
private readonly notificationsService: NotificationsService, 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; const targetUserId = dto.targetUserId;
if (!Types.ObjectId.isValid(postId) || !Types.ObjectId.isValid(targetUserId)) { if (!Types.ObjectId.isValid(postId) || !Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid collaboration request'); throw new BadRequestException('Invalid collaboration request');
} }
if (requesterId === targetUserId) { if (requesterId === targetUserId) {
throw new BadRequestException('You cannot invite yourself'); throw new BadRequestException('You cannot invite yourself');
} }
@@ -49,15 +67,19 @@ export class CollaborationRequestsService {
this.usersRepository.findById(targetUserId), this.usersRepository.findById(targetUserId),
this.blocksRepository.findAnyBetween(requesterId, targetUserId), this.blocksRepository.findAnyBetween(requesterId, targetUserId),
]); ]);
if (!post) { if (!post) {
throw new NotFoundException('Post not found'); throw new NotFoundException('Post not found');
} }
if (post.authorId.toString() !== requesterId) { if (post.authorId.toString() !== requesterId) {
throw new ForbiddenException('Only the post owner can invite collaborators'); throw new ForbiddenException('Only the post owner can invite collaborators');
} }
if (!targetUser || targetUser.isDisabled) { if (!targetUser || targetUser.isDisabled) {
throw new NotFoundException('Target user not found'); throw new NotFoundException('Target user not found');
} }
if (block) { if (block) {
throw new BadRequestException('You cannot invite this user'); throw new BadRequestException('You cannot invite this user');
} }
@@ -68,8 +90,10 @@ export class CollaborationRequestsService {
targetUserId: new Types.ObjectId(targetUserId), targetUserId: new Types.ObjectId(targetUserId),
status: 'pending', status: 'pending',
}; };
const collaborationDetails = this.buildCollaborationDetails(dto);
const collaborationDetails = await this.buildCollaborationDetailsWithFile(dto, file);
const existing = await this.collaborationRequestModel.findOne(filter).exec(); const existing = await this.collaborationRequestModel.findOne(filter).exec();
const request = existing const request = existing
? await this.updateExistingPendingRequest(existing.id, collaborationDetails) ? await this.updateExistingPendingRequest(existing.id, collaborationDetails)
: await this.collaborationRequestModel : await this.collaborationRequestModel
@@ -87,6 +111,7 @@ export class CollaborationRequestsService {
{ new: true, upsert: true, setDefaultsOnInsert: true }, { new: true, upsert: true, setDefaultsOnInsert: true },
) )
.exec(); .exec();
if (!request) { if (!request) {
throw new NotFoundException('Collaboration request not found'); throw new NotFoundException('Collaboration request not found');
} }
@@ -96,7 +121,87 @@ export class CollaborationRequestsService {
actorId: requesterId, actorId: requesterId,
recipientId: targetUserId, recipientId: targetUserId,
type: 'collaboration_request', 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, requestId: request.id,
metadata: collaborationDetails, metadata: collaborationDetails,
}); });
@@ -132,12 +237,15 @@ export class CollaborationRequestsService {
} }
const request = await this.collaborationRequestModel.findById(requestId).exec(); const request = await this.collaborationRequestModel.findById(requestId).exec();
if (!request) { if (!request) {
throw new NotFoundException('Collaboration request not found'); throw new NotFoundException('Collaboration request not found');
} }
if (request.requesterId.toString() !== requesterId) { if (request.requesterId.toString() !== requesterId) {
throw new ForbiddenException('Only the requester can cancel this collaboration request'); throw new ForbiddenException('Only the requester can cancel this collaboration request');
} }
if (request.status !== 'pending') { if (request.status !== 'pending') {
throw new BadRequestException('Only pending collaboration requests can be cancelled'); throw new BadRequestException('Only pending collaboration requests can be cancelled');
} }
@@ -149,7 +257,7 @@ export class CollaborationRequestsService {
actorId: requesterId, actorId: requesterId,
recipientId: request.targetUserId.toString(), recipientId: request.targetUserId.toString(),
type: 'collaboration_request_cancelled', type: 'collaboration_request_cancelled',
postId: request.postId.toString(), ...this.buildNotificationTargetForRequest(request, requesterId),
requestId: request.id, requestId: request.id,
metadata: { metadata: {
status: 'cancelled', status: 'cancelled',
@@ -171,10 +279,12 @@ export class CollaborationRequestsService {
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const filter: Record<string, unknown> = { const filter: Record<string, unknown> = {
[field]: new Types.ObjectId(userId), [field]: new Types.ObjectId(userId),
...(query.status ? { status: query.status } : {}), ...(query.status ? { status: query.status } : {}),
}; };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.populateRequestQuery(this.collaborationRequestModel.find(filter)) this.populateRequestQuery(this.collaborationRequestModel.find(filter))
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
@@ -183,19 +293,24 @@ export class CollaborationRequestsService {
.exec(), .exec(),
this.collaborationRequestModel.countDocuments(filter).exec(), this.collaborationRequestModel.countDocuments(filter).exec(),
]); ]);
return buildPaginatedResponse(items, { page, limit, total, offset: skip }); return buildPaginatedResponse(items, { page, limit, total, offset: skip });
} }
async approve(targetUserId: string, requestId: string) { async approve(targetUserId: string, requestId: string) {
const request = await this.updateStatus(targetUserId, requestId, 'approved'); const request = await this.updateStatus(targetUserId, requestId, 'approved');
if (request.postId) {
await this.postsRepository.updateById(request.postId.toString(), { await this.postsRepository.updateById(request.postId.toString(), {
$addToSet: { collaboratorIds: request.targetUserId }, $addToSet: { collaboratorIds: request.targetUserId },
}); });
}
await this.createCollaborationNotification({ await this.createCollaborationNotification({
actorId: targetUserId, actorId: targetUserId,
recipientId: request.requesterId.toString(), recipientId: request.requesterId.toString(),
type: 'collaboration_request_approved', type: 'collaboration_request_approved',
postId: request.postId.toString(), ...this.buildNotificationTargetForRequest(request, targetUserId),
requestId: request.id, requestId: request.id,
metadata: { metadata: {
status: 'approved', status: 'approved',
@@ -211,11 +326,12 @@ export class CollaborationRequestsService {
async reject(targetUserId: string, requestId: string) { async reject(targetUserId: string, requestId: string) {
const request = await this.updateStatus(targetUserId, requestId, 'rejected'); const request = await this.updateStatus(targetUserId, requestId, 'rejected');
await this.createCollaborationNotification({ await this.createCollaborationNotification({
actorId: targetUserId, actorId: targetUserId,
recipientId: request.requesterId.toString(), recipientId: request.requesterId.toString(),
type: 'collaboration_request_rejected', type: 'collaboration_request_rejected',
postId: request.postId.toString(), ...this.buildNotificationTargetForRequest(request, targetUserId),
requestId: request.id, requestId: request.id,
metadata: { metadata: {
status: 'rejected', status: 'rejected',
@@ -229,7 +345,9 @@ export class CollaborationRequestsService {
}; };
} }
private buildCollaborationDetails(dto: CreateCollaborationRequestDto) { private buildCollaborationDetails(
dto: CreateCollaborationRequestDto | CreateGeneralCollaborationRequestDto,
) {
return { return {
...(dto.collaborationType ? { collaborationType: dto.collaborationType } : {}), ...(dto.collaborationType ? { collaborationType: dto.collaborationType } : {}),
...(typeof dto.message === 'string' ? { message: dto.message.trim() } : {}), ...(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( private async updateExistingPendingRequest(
requestId: string, requestId: string,
collaborationDetails: Record<string, unknown>, collaborationDetails: Record<string, unknown>,
@@ -259,19 +462,24 @@ export class CollaborationRequestsService {
if (!Types.ObjectId.isValid(requestId)) { if (!Types.ObjectId.isValid(requestId)) {
throw new BadRequestException('Invalid collaboration request id'); throw new BadRequestException('Invalid collaboration request id');
} }
const request = await this.collaborationRequestModel.findById(requestId).exec(); const request = await this.collaborationRequestModel.findById(requestId).exec();
if (!request) { if (!request) {
throw new NotFoundException('Collaboration request not found'); throw new NotFoundException('Collaboration request not found');
} }
if (request.targetUserId.toString() !== targetUserId) { if (request.targetUserId.toString() !== targetUserId) {
throw new ForbiddenException('Only the target user can update this collaboration request'); throw new ForbiddenException('Only the target user can update this collaboration request');
} }
if (request.status !== 'pending') { if (request.status !== 'pending') {
throw new BadRequestException('Only pending collaboration requests can be updated'); throw new BadRequestException('Only pending collaboration requests can be updated');
} }
request.status = status; request.status = status;
await request.save(); await request.save();
return request; return request;
} }
@@ -312,7 +520,9 @@ export class CollaborationRequestsService {
| 'collaboration_request_approved' | 'collaboration_request_approved'
| 'collaboration_request_rejected' | 'collaboration_request_rejected'
| 'collaboration_request_cancelled'; | 'collaboration_request_cancelled';
postId: string; referenceId: string;
resourceType: string;
deepLink: string;
requestId: string; requestId: string;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}) { }) {
@@ -321,9 +531,9 @@ export class CollaborationRequestsService {
actorId: options.actorId, actorId: options.actorId,
recipientId: options.recipientId, recipientId: options.recipientId,
type: options.type, type: options.type,
referenceId: options.postId, referenceId: options.referenceId,
resourceType: 'post', resourceType: options.resourceType,
deepLink: `/posts/${options.postId}`, deepLink: options.deepLink,
metadata: { metadata: {
...(options.metadata ?? {}), ...(options.metadata ?? {}),
collaborationRequestId: options.requestId, 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 {
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 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 { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { CollaborationRequestsService } from './collaboration-requests.service'; import { CollaborationRequestsService } from './collaboration-requests.service';
import { CreateCollaborationRequestDto } from './dto/create-collaboration-request.dto'; import { CreateCollaborationRequestDto } from './dto/create-collaboration-request.dto';
type UploadedAudioFile = {
buffer: Buffer;
originalname: string;
mimetype: string;
size: number;
};
@ApiTags('Post Collaboration Requests') @ApiTags('Post Collaboration Requests')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -14,11 +30,14 @@ export class PostsCollaborationRequestsController {
constructor(private readonly collaborationRequestsService: CollaborationRequestsService) {} constructor(private readonly collaborationRequestsService: CollaborationRequestsService) {}
@Post(':postId/collaboration-requests') @Post(':postId/collaboration-requests')
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('attachmentUrl'))
async create( async create(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Param('postId') postId: string, @Param('postId') postId: string,
@Body() dto: CreateCollaborationRequestDto, @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 }) @Schema({ timestamps: true, versionKey: false })
export class CollaborationRequest { export class CollaborationRequest {
@Prop({ type: Types.ObjectId, ref: Post.name, required: true, index: true }) @Prop({
postId!: Types.ObjectId; 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 }) @Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
requesterId!: Types.ObjectId; 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,
);
}
}