Improve collaboration requests APIs
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -9,4 +9,7 @@ export enum NotificationType {
|
|||||||
REPLY = 'reply',
|
REPLY = 'reply',
|
||||||
SYSTEM = 'system',
|
SYSTEM = 'system',
|
||||||
COLLABORATION_REQUEST = 'collaboration_request',
|
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 { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
||||||
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 { CollaborationRequestQueryDto } from './dto/collaboration-request-query.dto';
|
||||||
import { CollaborationRequestsService } from './collaboration-requests.service';
|
import { CollaborationRequestsService } from './collaboration-requests.service';
|
||||||
|
|
||||||
@ApiTags('Collaboration Requests')
|
@ApiTags('Collaboration Requests')
|
||||||
@@ -18,6 +19,24 @@ export class CollaborationRequestsController {
|
|||||||
return this.collaborationRequestsService.getMine(user.sub, query);
|
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')
|
@Patch(':requestId/approve')
|
||||||
async approve(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
async approve(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
||||||
return this.collaborationRequestsService.approve(user.sub, requestId);
|
return this.collaborationRequestsService.approve(user.sub, requestId);
|
||||||
@@ -27,4 +46,9 @@ export class CollaborationRequestsController {
|
|||||||
async reject(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
async reject(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
|
||||||
return this.collaborationRequestsService.reject(user.sub, requestId);
|
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,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectModel } from '@nestjs/mongoose';
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
@@ -13,13 +14,18 @@ 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 { CollaborationRequestQueryDto } from './dto/collaboration-request-query.dto';
|
||||||
import {
|
import {
|
||||||
CollaborationRequest,
|
CollaborationRequest,
|
||||||
CollaborationRequestDocument,
|
CollaborationRequestDocument,
|
||||||
|
CollaborationRequestStatus,
|
||||||
} from './schemas/collaboration-request.schema';
|
} from './schemas/collaboration-request.schema';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollaborationRequestsService {
|
export class CollaborationRequestsService {
|
||||||
|
private readonly logger = new Logger(CollaborationRequestsService.name);
|
||||||
|
private readonly userSelect = 'name username stageName avatar isVerified isDisabled';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(CollaborationRequest.name)
|
@InjectModel(CollaborationRequest.name)
|
||||||
private readonly collaborationRequestModel: Model<CollaborationRequestDocument>,
|
private readonly collaborationRequestModel: Model<CollaborationRequestDocument>,
|
||||||
@@ -81,35 +87,96 @@ export class CollaborationRequestsService {
|
|||||||
{ new: true, upsert: true, setDefaultsOnInsert: true },
|
{ new: true, upsert: true, setDefaultsOnInsert: true },
|
||||||
)
|
)
|
||||||
.exec();
|
.exec();
|
||||||
|
if (!request) {
|
||||||
|
throw new NotFoundException('Collaboration request not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
await this.notificationsService.create({
|
await this.createCollaborationNotification({
|
||||||
actorId: requesterId,
|
actorId: requesterId,
|
||||||
recipientId: targetUserId,
|
recipientId: targetUserId,
|
||||||
type: 'collaboration_request',
|
type: 'collaboration_request',
|
||||||
referenceId: postId,
|
postId,
|
||||||
resourceType: 'post',
|
requestId: request.id,
|
||||||
deepLink: `/posts/${postId}`,
|
|
||||||
metadata: collaborationDetails,
|
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) {
|
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 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 = { 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([
|
const [items, total] = await Promise.all([
|
||||||
this.collaborationRequestModel
|
this.populateRequestQuery(this.collaborationRequestModel.find(filter))
|
||||||
.find(filter)
|
|
||||||
.populate({
|
|
||||||
path: 'requesterId',
|
|
||||||
select: 'name username stageName avatar isVerified isDisabled',
|
|
||||||
})
|
|
||||||
.populate({ path: 'postId' })
|
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -124,12 +191,42 @@ export class CollaborationRequestsService {
|
|||||||
await this.postsRepository.updateById(request.postId.toString(), {
|
await this.postsRepository.updateById(request.postId.toString(), {
|
||||||
$addToSet: { collaboratorIds: request.targetUserId },
|
$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) {
|
async reject(targetUserId: string, requestId: string) {
|
||||||
const request = await this.updateStatus(targetUserId, requestId, 'rejected');
|
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) {
|
private buildCollaborationDetails(dto: CreateCollaborationRequestDto) {
|
||||||
@@ -157,25 +254,87 @@ export class CollaborationRequestsService {
|
|||||||
private async updateStatus(
|
private async updateStatus(
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
status: 'approved' | 'rejected',
|
status: Extract<CollaborationRequestStatus, 'approved' | 'rejected'>,
|
||||||
) {
|
) {
|
||||||
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
|
const request = await this.collaborationRequestModel.findById(requestId).exec();
|
||||||
.findOneAndUpdate(
|
|
||||||
{
|
|
||||||
_id: new Types.ObjectId(requestId),
|
|
||||||
targetUserId: new Types.ObjectId(targetUserId),
|
|
||||||
status: 'pending',
|
|
||||||
},
|
|
||||||
{ status },
|
|
||||||
{ new: true },
|
|
||||||
)
|
|
||||||
.exec();
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
throw new NotFoundException('Collaboration request not found');
|
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;
|
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 const COLLABORATION_ATTACHMENT_TYPES = ['audio', 'demo', 'file'] as const;
|
||||||
export type CollaborationAttachmentType = (typeof COLLABORATION_ATTACHMENT_TYPES)[number];
|
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 })
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
export class CollaborationRequest {
|
export class CollaborationRequest {
|
||||||
@Prop({ type: Types.ObjectId, ref: Post.name, required: true, index: true })
|
@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 })
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
targetUserId!: Types.ObjectId;
|
targetUserId!: Types.ObjectId;
|
||||||
|
|
||||||
@Prop({ type: String, enum: ['pending', 'approved', 'rejected'], default: 'pending', index: true })
|
@Prop({
|
||||||
status!: 'pending' | 'approved' | 'rejected';
|
type: String,
|
||||||
|
enum: COLLABORATION_REQUEST_STATUSES,
|
||||||
|
default: 'pending',
|
||||||
|
index: true,
|
||||||
|
})
|
||||||
|
status!: CollaborationRequestStatus;
|
||||||
|
|
||||||
@Prop({
|
@Prop({
|
||||||
type: String,
|
type: String,
|
||||||
@@ -51,3 +64,4 @@ export class CollaborationRequest {
|
|||||||
export const CollaborationRequestSchema = SchemaFactory.createForClass(CollaborationRequest);
|
export const CollaborationRequestSchema = SchemaFactory.createForClass(CollaborationRequest);
|
||||||
CollaborationRequestSchema.index({ postId: 1, targetUserId: 1, status: 1 });
|
CollaborationRequestSchema.index({ postId: 1, targetUserId: 1, status: 1 });
|
||||||
CollaborationRequestSchema.index({ targetUserId: 1, status: 1, createdAt: -1 });
|
CollaborationRequestSchema.index({ targetUserId: 1, status: 1, createdAt: -1 });
|
||||||
|
CollaborationRequestSchema.index({ requesterId: 1, status: 1, createdAt: -1 });
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ describe('NotificationsService', () => {
|
|||||||
'save',
|
'save',
|
||||||
'share',
|
'share',
|
||||||
'collaboration_request',
|
'collaboration_request',
|
||||||
|
'collaboration_request_approved',
|
||||||
|
'collaboration_request_rejected',
|
||||||
'system',
|
'system',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -295,6 +297,8 @@ describe('NotificationsService', () => {
|
|||||||
'save',
|
'save',
|
||||||
'share',
|
'share',
|
||||||
'collaboration_request',
|
'collaboration_request',
|
||||||
|
'collaboration_request_approved',
|
||||||
|
'collaboration_request_rejected',
|
||||||
'system',
|
'system',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,12 +17,19 @@ const NOTIFICATION_CATEGORY_TYPES: Record<NotificationCategory, NotificationType
|
|||||||
'save',
|
'save',
|
||||||
'share',
|
'share',
|
||||||
'collaboration_request',
|
'collaboration_request',
|
||||||
|
'collaboration_request_approved',
|
||||||
|
'collaboration_request_rejected',
|
||||||
'system',
|
'system',
|
||||||
],
|
],
|
||||||
messages: ['message'],
|
messages: ['message'],
|
||||||
follows: ['follow'],
|
follows: ['follow'],
|
||||||
follow_requests: ['follow_request', 'follow_request_approved', 'follow_request_rejected'],
|
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'],
|
system: ['system'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -352,6 +359,12 @@ export class NotificationsService {
|
|||||||
return 'Notification';
|
return 'Notification';
|
||||||
case 'collaboration_request':
|
case 'collaboration_request':
|
||||||
return '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':
|
case 'follow_request':
|
||||||
return 'Follow request';
|
return 'Follow request';
|
||||||
case 'follow_request_approved':
|
case 'follow_request_approved':
|
||||||
@@ -379,6 +392,9 @@ export class NotificationsService {
|
|||||||
case 'system':
|
case 'system':
|
||||||
return 'system';
|
return 'system';
|
||||||
case 'collaboration_request':
|
case 'collaboration_request':
|
||||||
|
case 'collaboration_request_approved':
|
||||||
|
case 'collaboration_request_rejected':
|
||||||
|
case 'collaboration_request_cancelled':
|
||||||
return 'post';
|
return 'post';
|
||||||
case 'support_reply':
|
case 'support_reply':
|
||||||
case 'support_ticket_status':
|
case 'support_ticket_status':
|
||||||
|
|||||||
@@ -748,14 +748,7 @@ export class UsersService {
|
|||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||||
}),
|
}),
|
||||||
postsCollection.countDocuments({
|
this.countUserCollaborations(objectUserId),
|
||||||
isDeleted: false,
|
|
||||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
|
||||||
$or: [
|
|
||||||
{ authorId: objectUserId, 'taggedUserIds.0': { $exists: true } },
|
|
||||||
{ authorId: { $ne: objectUserId }, taggedUserIds: objectUserId },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
viewerUserId === userId
|
viewerUserId === userId
|
||||||
? Promise.resolve(false)
|
? Promise.resolve(false)
|
||||||
: followsCollection.countDocuments({
|
: followsCollection.countDocuments({
|
||||||
@@ -967,11 +960,7 @@ export class UsersService {
|
|||||||
isDeleted: { $ne: true },
|
isDeleted: { $ne: true },
|
||||||
isArchived: { $ne: true },
|
isArchived: { $ne: true },
|
||||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||||
$or: [
|
collaboratorIds: userId,
|
||||||
{ authorId: userId, 'taggedUserIds.0': { $exists: true } },
|
|
||||||
{ authorId: { $ne: userId }, taggedUserIds: userId },
|
|
||||||
{ collaboratorIds: userId },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم