Improve collaboration requests APIs
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-06-09 17:32:01 +03:00
الأصل 16de76d9eb
التزام ddac88582f
9 ملفات معدلة مع 454 إضافات و43 حذوفات

عرض الملف

@@ -9,4 +9,7 @@ export enum NotificationType {
REPLY = 'reply',
SYSTEM = 'system',
COLLABORATION_REQUEST = 'collaboration_request',
COLLABORATION_REQUEST_APPROVED = 'collaboration_request_approved',
COLLABORATION_REQUEST_REJECTED = 'collaboration_request_rejected',
COLLABORATION_REQUEST_CANCELLED = 'collaboration_request_cancelled',
}

عرض الملف

@@ -4,6 +4,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { CollaborationRequestQueryDto } from './dto/collaboration-request-query.dto';
import { CollaborationRequestsService } from './collaboration-requests.service';
@ApiTags('Collaboration Requests')
@@ -18,6 +19,24 @@ export class CollaborationRequestsController {
return this.collaborationRequestsService.getMine(user.sub, query);
}
@Get('received')
async received(
@CurrentUser() user: JwtPayload,
@Query() query: CollaborationRequestQueryDto,
) {
return this.collaborationRequestsService.listReceived(user.sub, query);
}
@Get('sent')
async sent(@CurrentUser() user: JwtPayload, @Query() query: CollaborationRequestQueryDto) {
return this.collaborationRequestsService.listSent(user.sub, query);
}
@Get(':requestId')
async getById(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
return this.collaborationRequestsService.getById(user.sub, requestId);
}
@Patch(':requestId/approve')
async approve(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
return this.collaborationRequestsService.approve(user.sub, requestId);
@@ -27,4 +46,9 @@ export class CollaborationRequestsController {
async reject(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
return this.collaborationRequestsService.reject(user.sub, requestId);
}
@Patch(':requestId/cancel')
async cancel(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
return this.collaborationRequestsService.cancel(user.sub, requestId);
}
}

عرض الملف

@@ -0,0 +1,188 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Types } from 'mongoose';
import { CollaborationRequestsService } from './collaboration-requests.service';
const chain = <T>(value: T) => ({
populate: jest.fn().mockReturnThis(),
sort: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue(value),
});
const createRequestDoc = (overrides: Record<string, any> = {}) => {
const requestId = overrides._id ?? new Types.ObjectId();
return {
_id: requestId,
id: requestId.toString(),
postId: overrides.postId ?? new Types.ObjectId(),
requesterId: overrides.requesterId ?? new Types.ObjectId(),
targetUserId: overrides.targetUserId ?? new Types.ObjectId(),
status: overrides.status ?? 'pending',
collaborationType: overrides.collaborationType ?? 'duet',
save: jest.fn().mockResolvedValue(undefined),
...overrides,
};
};
const createService = () => {
const model = {
findOne: jest.fn(),
findOneAndUpdate: jest.fn(),
findById: jest.fn(),
findByIdAndUpdate: jest.fn(),
find: jest.fn(),
countDocuments: jest.fn(),
};
const postsRepository = {
findById: jest.fn(),
updateById: jest.fn(),
};
const usersRepository = {
findById: jest.fn(),
};
const blocksRepository = {
findAnyBetween: jest.fn(),
};
const notificationsService = {
create: jest.fn(),
};
const service = new CollaborationRequestsService(
model as any,
postsRepository as any,
usersRepository as any,
blocksRepository as any,
notificationsService as any,
);
return {
service,
model,
postsRepository,
usersRepository,
blocksRepository,
notificationsService,
};
};
describe('CollaborationRequestsService', () => {
it('creates a pending collaboration request and notifies the target user', async () => {
const requesterId = new Types.ObjectId().toString();
const targetUserId = new Types.ObjectId().toString();
const postId = new Types.ObjectId().toString();
const request = createRequestDoc({
postId: new Types.ObjectId(postId),
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
});
const { service, model, postsRepository, usersRepository, blocksRepository, notificationsService } =
createService();
postsRepository.findById.mockResolvedValue({ authorId: new Types.ObjectId(requesterId) });
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.create(requesterId, postId, {
targetUserId,
collaborationType: 'duet',
message: 'Let us work',
}),
).resolves.toMatchObject({
message: 'Collaboration request sent',
request: expect.objectContaining({ populated: true }),
});
expect(notificationsService.create).toHaveBeenCalledWith(
expect.objectContaining({
actorId: requesterId,
recipientId: targetUserId,
type: 'collaboration_request',
referenceId: postId,
deepLink: `/posts/${postId}`,
}),
);
});
it('lists received requests with optional status filter', async () => {
const targetUserId = new Types.ObjectId().toString();
const items = [createRequestDoc({ targetUserId: new Types.ObjectId(targetUserId) })];
const { service, model } = createService();
model.find.mockReturnValue(chain(items));
model.countDocuments.mockReturnValue(chain(1));
const result = await service.listReceived(targetUserId, {
page: 1,
limit: 20,
status: 'approved',
});
expect(result.items).toHaveLength(1);
expect(model.find).toHaveBeenCalledWith({
targetUserId: new Types.ObjectId(targetUserId),
status: 'approved',
});
});
it('approves a pending request, updates post collaborators, and notifies requester', async () => {
const targetUserId = new Types.ObjectId().toString();
const requesterId = new Types.ObjectId().toString();
const postId = new Types.ObjectId();
const request = createRequestDoc({
postId,
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).toHaveBeenCalledWith(postId.toString(), {
$addToSet: { collaboratorIds: request.targetUserId },
});
expect(notificationsService.create).toHaveBeenCalledWith(
expect.objectContaining({
recipientId: requesterId,
type: 'collaboration_request_approved',
}),
);
});
it('allows only the requester to cancel a pending request', async () => {
const requesterId = new Types.ObjectId().toString();
const otherUserId = new Types.ObjectId().toString();
const request = createRequestDoc({ requesterId: new Types.ObjectId(requesterId) });
const { service, model } = createService();
model.findById.mockReturnValue(chain(request));
await expect(service.cancel(otherUserId, request.id)).rejects.toBeInstanceOf(ForbiddenException);
expect(request.save).not.toHaveBeenCalled();
});
it('rejects cancelling non-pending requests', async () => {
const requesterId = new Types.ObjectId().toString();
const request = createRequestDoc({
requesterId: new Types.ObjectId(requesterId),
status: 'approved',
});
const { service, model } = createService();
model.findById.mockReturnValue(chain(request));
await expect(service.cancel(requesterId, request.id)).rejects.toBeInstanceOf(BadRequestException);
expect(request.save).not.toHaveBeenCalled();
});
});

عرض الملف

@@ -2,6 +2,7 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
@@ -13,13 +14,18 @@ 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 { CollaborationRequestQueryDto } from './dto/collaboration-request-query.dto';
import {
CollaborationRequest,
CollaborationRequestDocument,
CollaborationRequestStatus,
} from './schemas/collaboration-request.schema';
@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>,
@@ -81,35 +87,96 @@ export class CollaborationRequestsService {
{ new: true, upsert: true, setDefaultsOnInsert: true },
)
.exec();
if (!request) {
throw new NotFoundException('Collaboration request not found');
}
if (!existing) {
await this.notificationsService.create({
await this.createCollaborationNotification({
actorId: requesterId,
recipientId: targetUserId,
type: 'collaboration_request',
referenceId: postId,
resourceType: 'post',
deepLink: `/posts/${postId}`,
postId,
requestId: request.id,
metadata: collaborationDetails,
});
}
return { message: 'Collaboration request sent', request };
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',
postId: request.postId.toString(),
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 = { targetUserId: new Types.ObjectId(targetUserId), status: 'pending' };
const filter: Record<string, unknown> = {
[field]: new Types.ObjectId(userId),
...(query.status ? { status: query.status } : {}),
};
const [items, total] = await Promise.all([
this.collaborationRequestModel
.find(filter)
.populate({
path: 'requesterId',
select: 'name username stageName avatar isVerified isDisabled',
})
.populate({ path: 'postId' })
this.populateRequestQuery(this.collaborationRequestModel.find(filter))
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
@@ -124,12 +191,42 @@ export class CollaborationRequestsService {
await this.postsRepository.updateById(request.postId.toString(), {
$addToSet: { collaboratorIds: request.targetUserId },
});
return { approved: true, request };
await this.createCollaborationNotification({
actorId: targetUserId,
recipientId: request.requesterId.toString(),
type: 'collaboration_request_approved',
postId: request.postId.toString(),
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');
return { rejected: true, request };
await this.createCollaborationNotification({
actorId: targetUserId,
recipientId: request.requesterId.toString(),
type: 'collaboration_request_rejected',
postId: request.postId.toString(),
requestId: request.id,
metadata: {
status: 'rejected',
collaborationType: request.collaborationType ?? null,
},
});
return {
rejected: true,
request: await this.findVisibleRequestOrFail(targetUserId, request.id),
};
}
private buildCollaborationDetails(dto: CreateCollaborationRequestDto) {
@@ -157,25 +254,87 @@ export class CollaborationRequestsService {
private async updateStatus(
targetUserId: string,
requestId: string,
status: 'approved' | 'rejected',
status: Extract<CollaborationRequestStatus, 'approved' | 'rejected'>,
) {
if (!Types.ObjectId.isValid(requestId)) {
throw new BadRequestException('Invalid collaboration request id');
}
const request = await this.collaborationRequestModel
.findOneAndUpdate(
{
_id: new Types.ObjectId(requestId),
targetUserId: new Types.ObjectId(targetUserId),
status: 'pending',
},
{ status },
{ new: true },
)
.exec();
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';
postId: string;
requestId: string;
metadata?: Record<string, unknown>;
}) {
try {
await this.notificationsService.create({
actorId: options.actorId,
recipientId: options.recipientId,
type: options.type,
referenceId: options.postId,
resourceType: 'post',
deepLink: `/posts/${options.postId}`,
metadata: {
...(options.metadata ?? {}),
collaborationRequestId: options.requestId,
},
});
} catch (error) {
this.logger.warn(
`Collaboration notification failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}

عرض الملف

@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsOptional } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import {
COLLABORATION_REQUEST_STATUSES,
CollaborationRequestStatus,
} from '../schemas/collaboration-request.schema';
export class CollaborationRequestQueryDto extends PaginationQueryDto {
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
@IsEnum(COLLABORATION_REQUEST_STATUSES)
status?: CollaborationRequestStatus;
}

عرض الملف

@@ -10,6 +10,14 @@ export type CollaborationType = (typeof COLLABORATION_TYPES)[number];
export const COLLABORATION_ATTACHMENT_TYPES = ['audio', 'demo', 'file'] as const;
export type CollaborationAttachmentType = (typeof COLLABORATION_ATTACHMENT_TYPES)[number];
export const COLLABORATION_REQUEST_STATUSES = [
'pending',
'approved',
'rejected',
'cancelled',
] as const;
export type CollaborationRequestStatus = (typeof COLLABORATION_REQUEST_STATUSES)[number];
@Schema({ timestamps: true, versionKey: false })
export class CollaborationRequest {
@Prop({ type: Types.ObjectId, ref: Post.name, required: true, index: true })
@@ -21,8 +29,13 @@ export class CollaborationRequest {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
targetUserId!: Types.ObjectId;
@Prop({ type: String, enum: ['pending', 'approved', 'rejected'], default: 'pending', index: true })
status!: 'pending' | 'approved' | 'rejected';
@Prop({
type: String,
enum: COLLABORATION_REQUEST_STATUSES,
default: 'pending',
index: true,
})
status!: CollaborationRequestStatus;
@Prop({
type: String,
@@ -51,3 +64,4 @@ export class CollaborationRequest {
export const CollaborationRequestSchema = SchemaFactory.createForClass(CollaborationRequest);
CollaborationRequestSchema.index({ postId: 1, targetUserId: 1, status: 1 });
CollaborationRequestSchema.index({ targetUserId: 1, status: 1, createdAt: -1 });
CollaborationRequestSchema.index({ requesterId: 1, status: 1, createdAt: -1 });

عرض الملف

@@ -175,6 +175,8 @@ describe('NotificationsService', () => {
'save',
'share',
'collaboration_request',
'collaboration_request_approved',
'collaboration_request_rejected',
'system',
],
},
@@ -295,6 +297,8 @@ describe('NotificationsService', () => {
'save',
'share',
'collaboration_request',
'collaboration_request_approved',
'collaboration_request_rejected',
'system',
],
},

عرض الملف

@@ -17,12 +17,19 @@ const NOTIFICATION_CATEGORY_TYPES: Record<NotificationCategory, NotificationType
'save',
'share',
'collaboration_request',
'collaboration_request_approved',
'collaboration_request_rejected',
'system',
],
messages: ['message'],
follows: ['follow'],
follow_requests: ['follow_request', 'follow_request_approved', 'follow_request_rejected'],
collaboration: ['collaboration_request'],
collaboration: [
'collaboration_request',
'collaboration_request_approved',
'collaboration_request_rejected',
'collaboration_request_cancelled',
],
system: ['system'],
};
@@ -352,6 +359,12 @@ export class NotificationsService {
return 'Notification';
case 'collaboration_request':
return 'Collaboration request';
case 'collaboration_request_approved':
return 'Collaboration request approved';
case 'collaboration_request_rejected':
return 'Collaboration request rejected';
case 'collaboration_request_cancelled':
return 'Collaboration request cancelled';
case 'follow_request':
return 'Follow request';
case 'follow_request_approved':
@@ -379,6 +392,9 @@ export class NotificationsService {
case 'system':
return 'system';
case 'collaboration_request':
case 'collaboration_request_approved':
case 'collaboration_request_rejected':
case 'collaboration_request_cancelled':
return 'post';
case 'support_reply':
case 'support_ticket_status':

عرض الملف

@@ -748,14 +748,7 @@ export class UsersService {
isDeleted: false,
moderationStatus: { $ne: ModerationStatus.HIDDEN },
}),
postsCollection.countDocuments({
isDeleted: false,
moderationStatus: { $ne: ModerationStatus.HIDDEN },
$or: [
{ authorId: objectUserId, 'taggedUserIds.0': { $exists: true } },
{ authorId: { $ne: objectUserId }, taggedUserIds: objectUserId },
],
}),
this.countUserCollaborations(objectUserId),
viewerUserId === userId
? Promise.resolve(false)
: followsCollection.countDocuments({
@@ -967,11 +960,7 @@ export class UsersService {
isDeleted: { $ne: true },
isArchived: { $ne: true },
moderationStatus: { $ne: ModerationStatus.HIDDEN },
$or: [
{ authorId: userId, 'taggedUserIds.0': { $exists: true } },
{ authorId: { $ne: userId }, taggedUserIds: userId },
{ collaboratorIds: userId },
],
collaboratorIds: userId,
});
}