feat: stabilize social backend contracts

هذا الالتزام موجود في:
boutmoun123
2026-05-31 16:13:23 +03:00
الأصل ad6da6754d
التزام 49e132909e
40 ملفات معدلة مع 12037 إضافات و9562 حذوفات

عرض الملف

@@ -13,6 +13,28 @@ export const apiEndpoints = {
revokeSuperAdminSession: (sessionId: string) => `/auth/superadmin/sessions/${sessionId}/revoke`,
},
users: {
meProfileSetup: "/users/me/profile-setup",
meMusicSetup: "/users/me/music-setup",
me: "/users/me",
publicList: "/users",
publicById: (userId: string) => `/users/${userId}`,
publicDiscover: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/users/discover?${toQueryString(params)}`,
publicProfileOverview: (userId: string) => `/users/${userId}/profile-overview`,
publicPresence: (userId: string) => `/users/${userId}/presence`,
publicPosts: (userId: string, params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/users/${userId}/posts?${toQueryString(params)}`,
follow: (userId: string) => `/users/${userId}/follow`,
unfollow: (userId: string) => `/users/${userId}/follow`,
followStatus: (userId: string) => `/users/${userId}/follow-status`,
followers: (userId: string, params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/users/${userId}/followers?${toQueryString(params)}`,
following: (userId: string, params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/users/${userId}/following?${toQueryString(params)}`,
myFollowers: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/users/me/followers?${toQueryString(params)}`,
myFollowing: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/users/me/following?${toQueryString(params)}`,
all: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/users/admin?${toQueryString(params)}`,
byId: (userId: string) => `/users/admin/${userId}`,
@@ -31,6 +53,24 @@ export const apiEndpoints = {
profileOverview: (userId: string) => `/users/admin/${userId}/profile-overview`,
},
posts: {
create: "/posts",
reelsCreate: "/posts/reels",
reels: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/posts/reels?${toQueryString(params)}`,
byId: (postId: string) => `/posts/${postId}`,
byUser: (userId: string, params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/posts/user/${userId}?${toQueryString(params)}`,
update: (postId: string) => `/posts/${postId}`,
repost: (postId: string) => `/posts/${postId}/repost`,
commentSettings: (postId: string) => `/posts/${postId}/comment-settings`,
pinProfile: (postId: string) => `/posts/${postId}/pin-profile`,
unpinProfile: (postId: string) => `/posts/${postId}/unpin-profile`,
archive: (postId: string) => `/posts/${postId}/archive`,
restoreArchive: (postId: string) => `/posts/${postId}/restore-archive`,
remove: (postId: string) => `/posts/${postId}`,
view: (postId: string) => `/posts/${postId}/view`,
play: (postId: string) => `/posts/${postId}/play`,
share: (postId: string) => `/posts/${postId}/share`,
moderation: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/posts/admin/moderation?${toQueryString(params)}`,
adminDelete: (postId: string) => `/posts/admin/${postId}`,
@@ -41,9 +81,39 @@ export const apiEndpoints = {
adminDelete: (commentId: string) => `/comments/admin/${commentId}`,
},
notifications: {
mine: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/notifications?${toQueryString(params)}`,
unreadCount: "/notifications/unread-count",
markRead: (notificationId: string) => `/notifications/${notificationId}/read`,
markAllRead: "/notifications/read-all",
superAdmin: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/notifications/superadmin?${toQueryString(params)}`,
},
follows: {
toggle: "/follows/toggle",
followers: (userId: string, params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/follows/followers/${userId}?${toQueryString(params)}`,
following: (userId: string, params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/follows/following/${userId}?${toQueryString(params)}`,
status: (targetUserId: string) => `/follows/status/${targetUserId}`,
suggestions: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/follows/suggestions?${toQueryString(params)}`,
requests: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/follows/requests?${toQueryString(params)}`,
approveRequest: (requestId: string) => `/follows/requests/${requestId}/approve`,
rejectRequest: (requestId: string) => `/follows/requests/${requestId}/reject`,
},
devices: {
register: "/devices/register",
unregister: "/devices/unregister",
},
collaborationRequests: {
create: (postId: string) => `/posts/${postId}/collaboration-requests`,
list: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/collaboration-requests?${toQueryString(params)}`,
approve: (requestId: string) => `/collaboration-requests/${requestId}/approve`,
reject: (requestId: string) => `/collaboration-requests/${requestId}/reject`,
},
reports: {
superAdmin: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/reports/superadmin?${toQueryString(params)}`,

عرض الملف

@@ -0,0 +1,173 @@
import { apiEndpoints } from "@/lib/api/endpoints";
import { fetchWithAuth } from "@/lib/auth/client";
import type {
CollaborationRequestItem,
CollaborationRequestsResponse,
DeviceItem,
FollowListResponse,
FollowStatusResponse,
NotificationsResponse,
PostsResponse,
PresenceResponse,
RegisterDevicePayload,
SuccessMessage,
UnregisterDevicePayload,
} from "@/types/api";
export async function getUserPresence(userId: string) {
return fetchWithAuth<PresenceResponse>(apiEndpoints.users.publicPresence(userId));
}
export async function listUserPosts(
userId: string,
params: Record<string, string | number | boolean | null | undefined> = {},
) {
return fetchWithAuth<PostsResponse>(apiEndpoints.users.publicPosts(userId, params));
}
export async function followUser(userId: string) {
return fetchWithAuth<FollowStatusResponse>(apiEndpoints.users.follow(userId), {
method: "POST",
body: JSON.stringify({}),
});
}
export async function unfollowUser(userId: string) {
return fetchWithAuth<FollowStatusResponse>(apiEndpoints.users.unfollow(userId), {
method: "DELETE",
});
}
export async function getFollowStatus(userId: string) {
return fetchWithAuth<FollowStatusResponse>(apiEndpoints.users.followStatus(userId));
}
export async function listUserFollowers(
userId: string,
params: Record<string, string | number | boolean | null | undefined> = {},
) {
return fetchWithAuth<FollowListResponse>(apiEndpoints.users.followers(userId, params));
}
export async function listUserFollowing(
userId: string,
params: Record<string, string | number | boolean | null | undefined> = {},
) {
return fetchWithAuth<FollowListResponse>(apiEndpoints.users.following(userId, params));
}
export async function registerDevice(payload: RegisterDevicePayload) {
return fetchWithAuth<DeviceItem>(apiEndpoints.devices.register, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function unregisterDevice(payload: UnregisterDevicePayload) {
return fetchWithAuth<SuccessMessage>(apiEndpoints.devices.unregister, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function createCollaborationRequest(postId: string, targetUserId: string) {
return fetchWithAuth<CollaborationRequestItem>(apiEndpoints.collaborationRequests.create(postId), {
method: "POST",
body: JSON.stringify({ targetUserId }),
});
}
export async function listCollaborationRequests(
params: Record<string, string | number | boolean | null | undefined> = {},
) {
return fetchWithAuth<CollaborationRequestsResponse>(apiEndpoints.collaborationRequests.list(params));
}
export async function approveCollaborationRequest(requestId: string) {
return fetchWithAuth<CollaborationRequestItem>(apiEndpoints.collaborationRequests.approve(requestId), {
method: "PATCH",
body: JSON.stringify({}),
});
}
export async function rejectCollaborationRequest(requestId: string) {
return fetchWithAuth<CollaborationRequestItem>(apiEndpoints.collaborationRequests.reject(requestId), {
method: "PATCH",
body: JSON.stringify({}),
});
}
export async function listMyNotifications(
params: Record<string, string | number | boolean | null | undefined> = {},
) {
return fetchWithAuth<NotificationsResponse>(apiEndpoints.notifications.mine(params));
}
export async function getMyUnreadNotificationCount() {
return fetchWithAuth<{ unreadCount: number }>(apiEndpoints.notifications.unreadCount);
}
export async function markMyNotificationRead(notificationId: string) {
return fetchWithAuth<SuccessMessage>(apiEndpoints.notifications.markRead(notificationId), {
method: "PATCH",
body: JSON.stringify({}),
});
}
export async function markAllMyNotificationsRead() {
return fetchWithAuth<SuccessMessage>(apiEndpoints.notifications.markAllRead, {
method: "PATCH",
body: JSON.stringify({}),
});
}
export type MobileSocialRouteGroup = {
title: string;
routes: string[];
};
export const mobileSocialRouteGroups: MobileSocialRouteGroup[] = [
{
title: "users",
routes: [
"GET /users/:userId",
"GET /users/:userId/presence",
"GET /users/:userId/posts",
"GET /users/:userId/profile-overview",
],
},
{
title: "follows",
routes: [
"POST /users/:userId/follow",
"DELETE /users/:userId/follow",
"GET /users/:userId/follow-status",
"GET /users/:userId/followers",
"GET /users/:userId/following",
"GET /users/me/followers",
"GET /users/me/following",
],
},
{
title: "devices",
routes: ["POST /devices/register", "POST /devices/unregister"],
},
{
title: "collaborationRequests",
routes: [
"POST /posts/:postId/collaboration-requests",
"GET /collaboration-requests",
"PATCH /collaboration-requests/:requestId/approve",
"PATCH /collaboration-requests/:requestId/reject",
],
},
{
title: "notifications",
routes: [
"GET /notifications",
"GET /notifications/unread-count",
"PATCH /notifications/:notificationId/read",
"PATCH /notifications/read-all",
],
},
];

عرض الملف

@@ -4,7 +4,8 @@
"notes": [
"This dashboard must authenticate with /auth/superadmin/* routes only.",
"User-only routes are not a valid contract for this dashboard unless they have a dedicated /admin or /superadmin variant.",
"The dashboard proxy should target the Nest backend API base URL defined in .env.local."
"The dashboard proxy should target the Nest backend API base URL defined in .env.local.",
"Mobile-facing routes are mirrored here for frontend contract visibility, but normal user routes require a normal user access token when called from Flutter."
],
"modules": {
"auth": [
@@ -15,6 +16,22 @@
"POST /auth/superadmin/sessions/:sessionId/revoke"
],
"users": [
"PATCH /users/me/profile-setup",
"PATCH /users/me/music-setup",
"PATCH /users/me",
"GET /users",
"GET /users/discover",
"GET /users/:userId",
"GET /users/:userId/presence",
"GET /users/:userId/profile-overview",
"GET /users/:userId/posts",
"POST /users/:userId/follow",
"DELETE /users/:userId/follow",
"GET /users/:userId/follow-status",
"GET /users/:userId/followers",
"GET /users/:userId/following",
"GET /users/me/followers",
"GET /users/me/following",
"GET /users/admin",
"GET /users/admin/admins",
"GET /users/admin/:userId",
@@ -31,6 +48,23 @@
"PATCH /superadmin/users/:userId/status"
],
"posts": [
"POST /posts",
"POST /posts/reels",
"GET /posts/reels",
"GET /posts/:postId",
"GET /posts/user/:userId",
"PATCH /posts/:postId",
"POST /posts/:postId/repost",
"PATCH /posts/:postId/comment-settings",
"PATCH /posts/:postId/pin-profile",
"PATCH /posts/:postId/unpin-profile",
"PATCH /posts/:postId/archive",
"PATCH /posts/:postId/restore-archive",
"DELETE /posts/:postId",
"POST /posts/:postId/view",
"POST /posts/:postId/play",
"POST /posts/:postId/share",
"POST /posts/:postId/collaboration-requests",
"GET /posts/admin/moderation",
"DELETE /posts/admin/:postId",
"PATCH /superadmin/posts/:postId/status"
@@ -41,8 +75,32 @@
"PATCH /superadmin/comments/:commentId/status"
],
"notifications": [
"GET /notifications",
"GET /notifications/unread-count",
"PATCH /notifications/:notificationId/read",
"PATCH /notifications/read-all",
"GET /notifications/superadmin"
],
"follows": [
"POST /follows/toggle",
"GET /follows/followers/:userId",
"GET /follows/following/:userId",
"GET /follows/status/:targetUserId",
"GET /follows/suggestions",
"GET /follows/requests",
"PATCH /follows/requests/:requestId/approve",
"PATCH /follows/requests/:requestId/reject"
],
"devices": [
"POST /devices/register",
"POST /devices/unregister"
],
"collaborationRequests": [
"POST /posts/:postId/collaboration-requests",
"GET /collaboration-requests",
"PATCH /collaboration-requests/:requestId/approve",
"PATCH /collaboration-requests/:requestId/reject"
],
"marketplace": [
"GET /marketplace/home",
"GET /marketplace/listings",

عرض الملف

@@ -210,8 +210,69 @@ export type SaveStatusResponse = {
export type FollowStatusResponse = {
following?: boolean;
isFollowing?: boolean;
isFollowedBy?: boolean;
isMutual?: boolean;
};
export type FollowListItem = {
_id?: string;
user?: ApiUser;
followerId?: ApiUser | string;
followingId?: ApiUser | string;
status?: "pending" | "accepted" | "rejected";
createdAt?: string;
updatedAt?: string;
};
export type FollowListResponse = PaginatedResponse<FollowListItem>;
export type PresenceResponse = {
userId: string;
isOnline: boolean;
lastSeenAt?: string | null;
};
export type RegisterDevicePayload = {
fcmToken: string;
platform: "android" | "ios" | "web";
deviceId?: string;
appVersion?: string;
locale?: string;
};
export type UnregisterDevicePayload = {
fcmToken?: string;
deviceId?: string;
};
export type DeviceItem = {
_id: string;
userId: string;
fcmToken: string;
platform: "android" | "ios" | "web";
deviceId?: string;
appVersion?: string;
locale?: string;
isActive?: boolean;
lastSeenAt?: string;
createdAt?: string;
updatedAt?: string;
};
export type CollaborationRequestStatus = "pending" | "approved" | "rejected";
export type CollaborationRequestItem = {
_id: string;
postId: ApiPost | string;
requesterId: ApiUser | string;
targetUserId: ApiUser | string;
status: CollaborationRequestStatus;
createdAt?: string;
updatedAt?: string;
};
export type CollaborationRequestsResponse = PaginatedResponse<CollaborationRequestItem>;
export type NotificationType =
| "like"
| "comment"
@@ -219,7 +280,10 @@ export type NotificationType =
| "message"
| "save"
| "share"
| "mention";
| "mention"
| "reply"
| "system"
| "collaboration_request";
export type NotificationItem = {
_id: string;

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

عرض الملف

@@ -16,6 +16,8 @@ import { AuditModule } from './modules/audit/audit.module';
import { BlocksModule } from './modules/blocks/blocks.module';
import { ChatModule } from './modules/chat/chat.module';
import { CommentsModule } from './modules/comments/comments.module';
import { CollaborationRequestsModule } from './modules/collaboration-requests/collaboration-requests.module';
import { DevicesModule } from './modules/devices/devices.module';
import { FeedModule } from './modules/feed/feed.module';
import { FollowsModule } from './modules/follows/follows.module';
import { LikesModule } from './modules/likes/likes.module';
@@ -50,6 +52,8 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
AuthModule,
PostsModule,
CommentsModule,
CollaborationRequestsModule,
DevicesModule,
LikesModule,
FollowsModule,
FeedModule,

عرض الملف

@@ -6,4 +6,7 @@ export enum NotificationType {
SAVE = 'save',
SHARE = 'share',
MENTION = 'mention',
REPLY = 'reply',
SYSTEM = 'system',
COLLABORATION_REQUEST = 'collaboration_request',
}

عرض الملف

@@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io';
import { ChatRealtimeService } from './chat-realtime.service';
import { ChatService } from './chat.service';
import { SendMessageDto } from './dto/send-message.dto';
import { UsersService } from '../users/users.service';
type SocketWithUser = Socket & { data: { userId?: string } };
@@ -21,12 +22,14 @@ type SocketWithUser = Socket & { data: { userId?: string } };
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly connectionCountsByUser = new Map<string, number>();
constructor(
private readonly chatService: ChatService,
private readonly chatRealtimeService: ChatRealtimeService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {}
afterInit(server: Server) {
@@ -49,6 +52,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return;
}
client.data.userId = payload.sub;
this.incrementUserConnection(payload.sub);
await this.usersService.setPresence(payload.sub, true);
await client.join(this.userRoom(payload.sub));
this.server.to(this.userRoom(payload.sub)).emit('presence', { userId: payload.sub, online: true });
} catch {
@@ -59,7 +64,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
handleDisconnect(client: SocketWithUser) {
const userId = client.data.userId;
if (userId) {
this.server.to(this.userRoom(userId)).emit('presence', { userId, online: false });
const remainingConnections = this.decrementUserConnection(userId);
if (remainingConnections === 0) {
void this.usersService.setPresence(userId, false);
this.server.to(this.userRoom(userId)).emit('presence', { userId, online: false });
}
}
}
@@ -140,4 +149,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
private conversationRoom(conversationId: string): string {
return `conversation:${conversationId}`;
}
private incrementUserConnection(userId: string): void {
this.connectionCountsByUser.set(userId, (this.connectionCountsByUser.get(userId) ?? 0) + 1);
}
private decrementUserConnection(userId: string): number {
const nextCount = Math.max(0, (this.connectionCountsByUser.get(userId) ?? 1) - 1);
if (nextCount === 0) {
this.connectionCountsByUser.delete(userId);
return 0;
}
this.connectionCountsByUser.set(userId, nextCount);
return nextCount;
}
}

عرض الملف

@@ -0,0 +1,39 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
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 { CollaborationRequestsService } from './collaboration-requests.service';
import { CreateCollaborationRequestDto } from './dto/create-collaboration-request.dto';
@ApiTags('Collaboration Requests')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class CollaborationRequestsController {
constructor(private readonly collaborationRequestsService: CollaborationRequestsService) {}
@Post('posts/:postId/collaboration-requests')
async create(
@CurrentUser() user: JwtPayload,
@Param('postId') postId: string,
@Body() dto: CreateCollaborationRequestDto,
) {
return this.collaborationRequestsService.create(user.sub, postId, dto.targetUserId);
}
@Get('collaboration-requests')
async mine(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
return this.collaborationRequestsService.getMine(user.sub, query);
}
@Patch('collaboration-requests/:requestId/approve')
async approve(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
return this.collaborationRequestsService.approve(user.sub, requestId);
}
@Patch('collaboration-requests/:requestId/reject')
async reject(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
return this.collaborationRequestsService.reject(user.sub, requestId);
}
}

عرض الملف

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { BlocksModule } from '../blocks/blocks.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PostsModule } from '../posts/posts.module';
import { UsersModule } from '../users/users.module';
import { CollaborationRequestsController } from './collaboration-requests.controller';
import { CollaborationRequestsService } from './collaboration-requests.service';
import {
CollaborationRequest,
CollaborationRequestSchema,
} from './schemas/collaboration-request.schema';
@Module({
imports: [
BlocksModule,
NotificationsModule,
PostsModule,
UsersModule,
MongooseModule.forFeature([{ name: CollaborationRequest.name, schema: CollaborationRequestSchema }]),
],
controllers: [CollaborationRequestsController],
providers: [CollaborationRequestsService],
})
export class CollaborationRequestsModule {}

عرض الملف

@@ -0,0 +1,136 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { BlocksRepository } from '../blocks/blocks.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { PostsRepository } from '../posts/posts.repository';
import { UsersRepository } from '../users/users.repository';
import {
CollaborationRequest,
CollaborationRequestDocument,
} from './schemas/collaboration-request.schema';
@Injectable()
export class CollaborationRequestsService {
constructor(
@InjectModel(CollaborationRequest.name)
private readonly collaborationRequestModel: Model<CollaborationRequestDocument>,
private readonly postsRepository: PostsRepository,
private readonly usersRepository: UsersRepository,
private readonly blocksRepository: BlocksRepository,
private readonly notificationsService: NotificationsService,
) {}
async create(requesterId: string, postId: string, targetUserId: string) {
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');
}
const [post, targetUser, block] = await Promise.all([
this.postsRepository.findById(postId),
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');
}
const filter = {
postId: new Types.ObjectId(postId),
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
status: 'pending',
};
const existing = await this.collaborationRequestModel.findOne(filter).exec();
const request = existing ?? await this.collaborationRequestModel
.findOneAndUpdate(
filter,
{
$setOnInsert: {
postId: new Types.ObjectId(postId),
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
status: 'pending',
},
},
{ new: true, upsert: true, setDefaultsOnInsert: true },
)
.exec();
if (!existing) {
await this.notificationsService.create({
actorId: requesterId,
recipientId: targetUserId,
type: 'collaboration_request',
referenceId: postId,
resourceType: 'post',
deepLink: `/posts/${postId}`,
});
}
return { message: 'Collaboration request sent', request };
}
async getMine(targetUserId: string, query: PaginationQueryDto) {
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 [items, total] = await Promise.all([
this.collaborationRequestModel
.find(filter)
.populate({ path: 'requesterId', select: 'name username stageName avatar isVerified isDisabled' })
.populate({ path: 'postId' })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.exec(),
this.collaborationRequestModel.countDocuments(filter).exec(),
]);
return buildPaginatedResponse(items, { page, limit, total, offset: skip });
}
async approve(targetUserId: string, requestId: string) {
const request = await this.updateStatus(targetUserId, requestId, 'approved');
await this.postsRepository.updateById(request.postId.toString(), {
$addToSet: { collaboratorIds: request.targetUserId },
});
return { approved: true, request };
}
async reject(targetUserId: string, requestId: string) {
const request = await this.updateStatus(targetUserId, requestId, 'rejected');
return { rejected: true, request };
}
private async updateStatus(targetUserId: string, requestId: string, status: '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();
if (!request) {
throw new NotFoundException('Collaboration request not found');
}
return request;
}
}

عرض الملف

@@ -0,0 +1,6 @@
import { IsMongoId } from 'class-validator';
export class CreateCollaborationRequestDto {
@IsMongoId()
targetUserId!: string;
}

عرض الملف

@@ -0,0 +1,25 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { Post } from '../../posts/schemas/post.schema';
import { User } from '../../users/schemas/user.schema';
export type CollaborationRequestDocument = HydratedDocument<CollaborationRequest>;
@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: User.name, required: true, index: true })
requesterId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
targetUserId!: Types.ObjectId;
@Prop({ enum: ['pending', 'approved', 'rejected'], default: 'pending', index: true })
status!: 'pending' | 'approved' | 'rejected';
}
export const CollaborationRequestSchema = SchemaFactory.createForClass(CollaborationRequest);
CollaborationRequestSchema.index({ postId: 1, targetUserId: 1, status: 1 });
CollaborationRequestSchema.index({ targetUserId: 1, status: 1, createdAt: -1 });

عرض الملف

@@ -0,0 +1,26 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, 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 { RegisterDeviceDto } from './dto/register-device.dto';
import { UnregisterDeviceDto } from './dto/unregister-device.dto';
import { DevicesService } from './devices.service';
@ApiTags('Devices')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('devices')
export class DevicesController {
constructor(private readonly devicesService: DevicesService) {}
@Post('register')
async register(@CurrentUser() user: JwtPayload, @Body() dto: RegisterDeviceDto) {
return this.devicesService.register(user.sub, dto);
}
@Post('unregister')
async unregister(@CurrentUser() user: JwtPayload, @Body() dto: UnregisterDeviceDto) {
return this.devicesService.unregister(user.sub, dto);
}
}

عرض الملف

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { DevicesController } from './devices.controller';
import { DevicesRepository } from './devices.repository';
import { DevicesService } from './devices.service';
import { Device, DeviceSchema } from './schemas/device.schema';
@Module({
imports: [MongooseModule.forFeature([{ name: Device.name, schema: DeviceSchema }])],
controllers: [DevicesController],
providers: [DevicesService, DevicesRepository],
exports: [DevicesService, DevicesRepository],
})
export class DevicesModule {}

عرض الملف

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { Device, DeviceDocument } from './schemas/device.schema';
@Injectable()
export class DevicesRepository {
constructor(@InjectModel(Device.name) private readonly deviceModel: Model<DeviceDocument>) {}
async upsert(userId: string, payload: Omit<Partial<Device>, 'userId'>): Promise<DeviceDocument> {
const filter = payload.deviceId
? { userId: new Types.ObjectId(userId), deviceId: payload.deviceId }
: { userId: new Types.ObjectId(userId), fcmToken: payload.fcmToken };
return this.deviceModel
.findOneAndUpdate(
filter,
{
$set: {
...payload,
userId: new Types.ObjectId(userId),
isActive: true,
lastSeenAt: new Date(),
},
},
{ new: true, upsert: true, setDefaultsOnInsert: true },
)
.exec();
}
async deactivate(userId: string, payload: { fcmToken?: string; deviceId?: string }): Promise<DeviceDocument | null> {
const filter: Record<string, unknown> = { userId: new Types.ObjectId(userId) };
if (payload.deviceId) {
filter.deviceId = payload.deviceId;
} else if (payload.fcmToken) {
filter.fcmToken = payload.fcmToken;
} else {
return null;
}
return this.deviceModel
.findOneAndUpdate(filter, { isActive: false, lastSeenAt: new Date() }, { new: true })
.exec();
}
}

عرض الملف

@@ -0,0 +1,45 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { RegisterDeviceDto } from './dto/register-device.dto';
import { UnregisterDeviceDto } from './dto/unregister-device.dto';
import { DevicesRepository } from './devices.repository';
@Injectable()
export class DevicesService {
constructor(private readonly devicesRepository: DevicesRepository) {}
async register(userId: string, dto: RegisterDeviceDto) {
const fcmToken = dto.fcmToken.trim();
if (!fcmToken) {
throw new BadRequestException('fcmToken is required');
}
const device = await this.devicesRepository.upsert(userId, {
fcmToken,
platform: dto.platform,
deviceId: dto.deviceId?.trim() ?? '',
appVersion: dto.appVersion?.trim() ?? '',
locale: dto.locale?.trim() ?? '',
});
return {
message: 'Device registered successfully',
device,
};
}
async unregister(userId: string, dto: UnregisterDeviceDto) {
if (!dto.deviceId?.trim() && !dto.fcmToken?.trim()) {
throw new BadRequestException('deviceId or fcmToken is required');
}
const device = await this.devicesRepository.deactivate(userId, {
deviceId: dto.deviceId?.trim(),
fcmToken: dto.fcmToken?.trim(),
});
return {
message: 'Device unregistered successfully',
device,
};
}
}

عرض الملف

@@ -0,0 +1,25 @@
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
export class RegisterDeviceDto {
@IsString()
@MaxLength(4096)
fcmToken!: string;
@IsEnum(['android', 'ios', 'web'])
platform!: 'android' | 'ios' | 'web';
@IsOptional()
@IsString()
@MaxLength(160)
deviceId?: string;
@IsOptional()
@IsString()
@MaxLength(60)
appVersion?: string;
@IsOptional()
@IsString()
@MaxLength(20)
locale?: string;
}

عرض الملف

@@ -0,0 +1,13 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UnregisterDeviceDto {
@IsOptional()
@IsString()
@MaxLength(4096)
fcmToken?: string;
@IsOptional()
@IsString()
@MaxLength(160)
deviceId?: string;
}

عرض الملف

@@ -0,0 +1,36 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
export type DeviceDocument = HydratedDocument<Device>;
@Schema({ timestamps: true, versionKey: false })
export class Device {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
userId!: Types.ObjectId;
@Prop({ required: true, trim: true, index: true })
fcmToken!: string;
@Prop({ enum: ['android', 'ios', 'web'], required: true, index: true })
platform!: 'android' | 'ios' | 'web';
@Prop({ default: '', trim: true, index: true })
deviceId!: string;
@Prop({ default: '', trim: true })
appVersion!: string;
@Prop({ default: '', trim: true })
locale!: string;
@Prop({ default: true, index: true })
isActive!: boolean;
@Prop({ type: Date, default: null })
lastSeenAt?: Date | null;
}
export const DeviceSchema = SchemaFactory.createForClass(Device);
DeviceSchema.index({ userId: 1, fcmToken: 1 }, { unique: true });
DeviceSchema.index({ userId: 1, deviceId: 1 }, { sparse: true });

عرض الملف

@@ -0,0 +1,58 @@
import { Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
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 { FollowsService } from './follows.service';
@ApiTags('Users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('users')
export class FollowsUsersController {
constructor(private readonly followsService: FollowsService) {}
@Get('me/followers')
async myFollowers(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowers(user.sub, query, user.sub);
}
@Get('me/following')
async myFollowing(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowing(user.sub, query, user.sub);
}
@Post(':userId/follow')
async followUser(@CurrentUser() user: JwtPayload, @Param('userId') targetUserId: string) {
return this.followsService.followUser(user.sub, targetUserId);
}
@Delete(':userId/follow')
async unfollowUser(@CurrentUser() user: JwtPayload, @Param('userId') targetUserId: string) {
return this.followsService.unfollowUser(user.sub, targetUserId);
}
@Get(':userId/follow-status')
async followStatus(@CurrentUser() user: JwtPayload, @Param('userId') targetUserId: string) {
return this.followsService.getFollowStatus(user.sub, targetUserId);
}
@Get(':userId/followers')
async followers(
@CurrentUser() user: JwtPayload,
@Param('userId') targetUserId: string,
@Query() query: PaginationQueryDto,
) {
return this.followsService.getFollowers(targetUserId, query, user.sub);
}
@Get(':userId/following')
async following(
@CurrentUser() user: JwtPayload,
@Param('userId') targetUserId: string,
@Query() query: PaginationQueryDto,
) {
return this.followsService.getFollowing(targetUserId, query, user.sub);
}
}

عرض الملف

@@ -24,15 +24,23 @@ export class FollowsController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('followers/:userId')
async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowers(userId, query);
async followers(
@CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PaginationQueryDto,
) {
return this.followsService.getFollowers(userId, query, user.sub);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('following/:userId')
async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowing(userId, query);
async following(
@CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PaginationQueryDto,
) {
return this.followsService.getFollowing(userId, query, user.sub);
}
@ApiBearerAuth()

عرض الملف

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { BlocksModule } from '../blocks/blocks.module';
import { OutboxModule } from '../outbox/outbox.module';
import { UsersModule } from '../users/users.module';
import { FollowsController } from './follows.controller';
import { FollowsUsersController } from './follows-users.controller';
import { FollowsService } from './follows.service';
import { FollowsRepository } from './follows.repository';
import { FollowRequest, FollowRequestSchema } from './schemas/follow-request.schema';
@@ -10,6 +12,7 @@ import { Follow, FollowSchema } from './schemas/follow.schema';
@Module({
imports: [
BlocksModule,
UsersModule,
OutboxModule,
MongooseModule.forFeature([
@@ -23,7 +26,7 @@ import { Follow, FollowSchema } from './schemas/follow.schema';
},
]),
],
controllers: [FollowsController],
controllers: [FollowsController, FollowsUsersController],
providers: [FollowsService, FollowsRepository],
exports: [FollowsService, FollowsRepository],
})

عرض الملف

@@ -39,6 +39,15 @@ export class FollowsRepository {
return follow;
}
isDuplicateKeyError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: number }).code === 11000
);
}
async deleteById(id: string, session?: ClientSession): Promise<void> {
await this.followModel.findByIdAndDelete(id, { session }).exec();
}
@@ -83,6 +92,28 @@ export class FollowsRepository {
.exec();
}
async findFollowerUserIds(followingId: string, skip: number, limit: number, sort: Record<string, 1 | -1>) {
return this.followModel
.find({ followingId: new Types.ObjectId(followingId) })
.select({ followerId: 1 })
.sort(sort)
.skip(skip)
.limit(limit)
.lean()
.exec();
}
async findFollowingUserIds(followerId: string, skip: number, limit: number, sort: Record<string, 1 | -1>) {
return this.followModel
.find({ followerId: new Types.ObjectId(followerId) })
.select({ followingId: 1 })
.sort(sort)
.skip(skip)
.limit(limit)
.lean()
.exec();
}
async upsertPendingRequest(requesterId: string, targetUserId: string): Promise<FollowRequestDocument> {
return this.followRequestModel
.findOneAndUpdate(

عرض الملف

@@ -21,12 +21,16 @@ describe('FollowsService', () => {
const outboxService = {
enqueueFollowNotification: jest.fn().mockRejectedValue(new Error('socket down')),
};
const blocksRepository = {
findAnyBetween: jest.fn().mockResolvedValue(null),
};
const service = new FollowsService(
followsRepository as any,
usersRepository as any,
outboxService as any,
{ bumpGlobalVersion: jest.fn().mockResolvedValue(1) } as any,
blocksRepository as any,
);
await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({
@@ -40,4 +44,51 @@ describe('FollowsService', () => {
'follow-1',
);
});
it('prevents users from following themselves', async () => {
const userId = '507f1f77bcf86cd799439011';
const service = new FollowsService(
{} as any,
{} as any,
{} as any,
{ bumpGlobalVersion: jest.fn() } as any,
{ findAnyBetween: jest.fn() } as any,
);
await expect(service.followUser(userId, userId)).rejects.toThrow('You cannot follow yourself');
});
it('returns already-following response without creating duplicate notification', async () => {
const currentUserId = '507f1f77bcf86cd799439011';
const targetUserId = '507f191e810c19729de860ea';
const followsRepository = {
findOne: jest.fn().mockResolvedValue({ id: 'existing-follow' }),
count: jest.fn().mockResolvedValueOnce(1).mockResolvedValueOnce(2),
create: jest.fn(),
};
const usersRepository = {
findById: jest.fn().mockResolvedValue({ id: targetUserId, isDisabled: false, isPrivate: false }),
setFollowingCount: jest.fn(),
setFollowersCount: jest.fn(),
};
const outboxService = {
enqueueFollowNotification: jest.fn(),
};
const service = new FollowsService(
followsRepository as any,
usersRepository as any,
outboxService as any,
{ bumpGlobalVersion: jest.fn() } as any,
{ findAnyBetween: jest.fn().mockResolvedValue(null) } as any,
);
await expect(service.followUser(currentUserId, targetUserId)).resolves.toMatchObject({
message: 'Already following user',
isFollowing: true,
followersCount: 2,
followingCount: 1,
});
expect(followsRepository.create).not.toHaveBeenCalled();
expect(outboxService.enqueueFollowNotification).not.toHaveBeenCalled();
});
});

عرض الملف

@@ -4,6 +4,7 @@ import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { BlocksRepository } from '../blocks/blocks.repository';
import { OutboxService } from '../outbox/outbox.service';
import { UsersRepository } from '../users/users.repository';
import { UserDocument } from '../users/schemas/user.schema';
@@ -19,23 +20,19 @@ export class FollowsService {
private readonly usersRepository: UsersRepository,
private readonly outboxService: OutboxService,
private readonly feedVersionService: FeedVersionService,
private readonly blocksRepository: BlocksRepository,
) {}
async toggleFollow(currentUserId: string, dto: ToggleFollowDto) {
const targetUserId = dto.targetUserId;
if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id');
}
if (currentUserId === targetUserId) {
throw new BadRequestException('You cannot follow yourself');
}
const targetUser = await this.usersRepository.findById(targetUserId);
if (!targetUser) {
throw new NotFoundException('Target user not found');
}
const targetUser = await this.findActiveTargetUser(targetUserId);
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
@@ -46,37 +43,107 @@ export class FollowsService {
return { following: false };
}
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
if (block) {
throw new BadRequestException('You cannot follow this user');
}
if (targetUser.isPrivate) {
const request = await this.followsRepository.upsertPendingRequest(currentUserId, targetUserId);
return { following: false, requested: true, requestId: request.id };
}
const follow = await this.followsRepository.create(currentUserId, targetUserId);
let followId: string | null = null;
try {
const follow = await this.followsRepository.create(currentUserId, targetUserId);
followId = follow.id;
} catch (error) {
if (!this.followsRepository.isDuplicateKeyError(error)) {
throw error;
}
}
await this.syncFollowCounts(currentUserId, targetUserId);
await this.feedVersionService.bumpGlobalVersion();
try {
await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id);
} catch (error) {
this.logger.warn(
`Follow notification failed for actor=${currentUserId} recipient=${targetUserId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
if (followId) {
await this.enqueueFollowNotification(currentUserId, targetUserId, followId);
}
return { following: true };
}
async getFollowers(userId: string, query: PaginationQueryDto) {
async followUser(currentUserId: string, targetUserId: string) {
await this.assertCanFollowTarget(currentUserId, targetUserId);
const targetUser = await this.findActiveTargetUser(targetUserId);
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
if (existing) {
const counts = await this.syncFollowCounts(currentUserId, targetUserId);
return this.buildFollowActionResponse('Already following user', true, targetUserId, counts);
}
if (targetUser.isPrivate) {
const request = await this.followsRepository.upsertPendingRequest(currentUserId, targetUserId);
const counts = await this.getFollowCounts(currentUserId, targetUserId);
return {
...this.buildFollowActionResponse('Follow request sent', false, targetUserId, counts),
requested: true,
requestId: request.id,
};
}
let followId: string | null = null;
try {
const follow = await this.followsRepository.create(currentUserId, targetUserId);
followId = follow.id;
} catch (error) {
if (!this.followsRepository.isDuplicateKeyError(error)) {
throw error;
}
}
const counts = await this.syncFollowCounts(currentUserId, targetUserId);
await this.feedVersionService.bumpGlobalVersion();
if (followId) {
await this.enqueueFollowNotification(currentUserId, targetUserId, followId);
}
return this.buildFollowActionResponse('User followed successfully', true, targetUserId, counts);
}
async unfollowUser(currentUserId: string, targetUserId: string) {
if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id');
}
if (currentUserId === targetUserId) {
throw new BadRequestException('You cannot follow yourself');
}
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
if (existing) {
await this.followsRepository.deleteById(existing.id);
await this.feedVersionService.bumpGlobalVersion();
}
const counts = await this.syncFollowCounts(currentUserId, targetUserId);
return this.buildFollowActionResponse('User unfollowed successfully', false, targetUserId, counts);
}
async getFollowers(userId: string, query: PaginationQueryDto, viewerUserId?: string) {
await this.assertListTargetExists(userId);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.followsRepository.findMany({ followingId: userId }, skip, limit, sort),
const [followRows, total] = await Promise.all([
this.followsRepository.findFollowerUserIds(userId, skip, limit, sort),
this.followsRepository.count({ followingId: userId }),
]);
const items = await this.decorateFollowUsers(
followRows.map((row) => row.followerId.toString()),
viewerUserId,
);
return buildPaginatedResponse(items, {
page,
@@ -86,15 +153,20 @@ export class FollowsService {
});
}
async getFollowing(userId: string, query: PaginationQueryDto) {
async getFollowing(userId: string, query: PaginationQueryDto, viewerUserId?: string) {
await this.assertListTargetExists(userId);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.followsRepository.findMany({ followerId: userId }, skip, limit, sort),
const [followRows, total] = await Promise.all([
this.followsRepository.findFollowingUserIds(userId, skip, limit, sort),
this.followsRepository.count({ followerId: userId }),
]);
const items = await this.decorateFollowUsers(
followRows.map((row) => row.followingId.toString()),
viewerUserId,
);
return buildPaginatedResponse(items, {
page,
@@ -110,16 +182,28 @@ export class FollowsService {
}
if (currentUserId === targetUserId) {
return { following: false, targetUserId };
return {
following: false,
isFollowing: false,
isFollowedBy: false,
isMutual: false,
targetUserId,
};
}
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
const [existing, reverseExisting] = await Promise.all([
this.followsRepository.findOne(currentUserId, targetUserId),
this.followsRepository.findOne(targetUserId, currentUserId),
]);
const pendingRequest =
currentUserId === targetUserId
? null
: await this.followsRepository.findPendingRequest(currentUserId, targetUserId);
return {
following: !!existing,
isFollowing: !!existing,
isFollowedBy: !!reverseExisting,
isMutual: !!existing && !!reverseExisting,
requested: !!pendingRequest,
targetUserId,
};
@@ -282,7 +366,10 @@ export class FollowsService {
return count;
}
private async syncFollowCounts(currentUserId: string, targetUserId: string): Promise<void> {
private async syncFollowCounts(
currentUserId: string,
targetUserId: string,
): Promise<{ followingCount: number; followersCount: number }> {
const [followingCount, followersCount] = await Promise.all([
this.followsRepository.count({ followerId: currentUserId }),
this.followsRepository.count({ followingId: targetUserId }),
@@ -292,5 +379,102 @@ export class FollowsService {
this.usersRepository.setFollowingCount(currentUserId, followingCount),
this.usersRepository.setFollowersCount(targetUserId, followersCount),
]);
return { followingCount, followersCount };
}
private async getFollowCounts(
currentUserId: string,
targetUserId: string,
): Promise<{ followingCount: number; followersCount: number }> {
const [followingCount, followersCount] = await Promise.all([
this.followsRepository.count({ followerId: currentUserId }),
this.followsRepository.count({ followingId: targetUserId }),
]);
return { followingCount, followersCount };
}
private async assertCanFollowTarget(currentUserId: string, targetUserId: string): Promise<void> {
if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id');
}
if (currentUserId === targetUserId) {
throw new BadRequestException('You cannot follow yourself');
}
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
if (block) {
throw new BadRequestException('You cannot follow this user');
}
}
private async findActiveTargetUser(targetUserId: string): Promise<UserDocument> {
const targetUser = await this.usersRepository.findById(targetUserId);
if (!targetUser || targetUser.isDisabled) {
throw new NotFoundException('Target user not found');
}
return targetUser;
}
private async assertListTargetExists(userId: string): Promise<void> {
if (!Types.ObjectId.isValid(userId)) {
throw new BadRequestException('Invalid user id');
}
await this.findActiveTargetUser(userId);
}
private async enqueueFollowNotification(actorId: string, recipientId: string, followId: string): Promise<void> {
try {
await this.outboxService.enqueueFollowNotification(actorId, recipientId, followId);
} catch (error) {
this.logger.warn(
`Follow notification failed for actor=${actorId} recipient=${recipientId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
private buildFollowActionResponse(
message: string,
isFollowing: boolean,
targetUserId: string,
counts: { followingCount: number; followersCount: number },
) {
return {
message,
following: isFollowing,
isFollowing,
targetUserId,
followersCount: counts.followersCount,
followingCount: counts.followingCount,
};
}
private async decorateFollowUsers(userIds: string[], viewerUserId?: string) {
const users = await this.usersRepository.findManyByIds(userIds);
const usersById = new Map(users.map((user) => [user.id, user]));
const viewerFollowingIds = viewerUserId ? new Set(await this.followsRepository.findFollowingIds(viewerUserId)) : new Set();
return userIds
.map((id) => usersById.get(id))
.filter((user): user is UserDocument => !!user)
.map((user) => {
const object = user.toObject();
return {
_id: object._id,
name: object.name,
stageName: object.stageName,
username: object.username,
avatar: object.avatar,
isVerified: object.isVerified,
isDisabled: object.isDisabled,
followersCount: object.followersCount ?? 0,
followingCount: object.followingCount ?? 0,
isFollowing: viewerFollowingIds.has(user.id),
};
});
}
}

عرض الملف

@@ -15,3 +15,5 @@ export class Follow {
export const FollowSchema = SchemaFactory.createForClass(Follow);
FollowSchema.index({ followerId: 1, followingId: 1 }, { unique: true });
FollowSchema.index({ followerId: 1, createdAt: -1 });
FollowSchema.index({ followingId: 1, createdAt: -1 });

عرض الملف

@@ -92,22 +92,28 @@ export class NotificationsRepository {
}
async markRead(recipientId: string, notificationId: string): Promise<NotificationDocument | null> {
const updated = await this.notificationModel
.findOneAndUpdate(
{
_id: new Types.ObjectId(notificationId),
recipientId: new Types.ObjectId(recipientId),
},
{
read: true,
readAt: new Date(),
},
{ new: true },
)
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
const notification = await this.notificationModel
.findOne({
_id: new Types.ObjectId(notificationId),
recipientId: new Types.ObjectId(recipientId),
})
.exec();
return updated;
if (!notification) {
return null;
}
if (notification.read) {
await notification.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' });
return notification;
}
notification.read = true;
notification.readAt = notification.readAt ?? new Date();
await notification.save();
await notification.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' });
return notification;
}
async markAllRead(recipientId: string): Promise<number> {

عرض الملف

@@ -47,6 +47,7 @@ describe('NotificationsService', () => {
await expect(service.markAllRead('user-1')).resolves.toEqual({
message: 'All notifications marked as read',
modifiedCount: 4,
updatedCount: 4,
unreadCount: 2,
});
@@ -70,4 +71,68 @@ describe('NotificationsService', () => {
await expect(service.markRead('user-1', 'invalid-id')).rejects.toBeInstanceOf(NotFoundException);
expect(notificationsRepository.markRead).not.toHaveBeenCalled();
});
it('returns total unread count for the current user in the notifications list', async () => {
const notificationsRepository = {
findMine: jest.fn().mockResolvedValue([{ id: 'notification-1' }]),
countMine: jest.fn().mockResolvedValue(30),
countUnread: jest.fn().mockResolvedValue(7),
};
const notificationsGateway = {
emitUnreadCount: jest.fn(),
};
const service = new NotificationsService(
notificationsRepository as any,
notificationsGateway as any,
);
await expect(
service.getMine('507f1f77bcf86cd799439011', {
page: 1,
limit: 20,
sortOrder: 'desc' as any,
}),
).resolves.toMatchObject({
count: 1,
page: 1,
limit: 20,
total: 30,
unreadCount: 7,
pagination: {
mode: 'offset',
hasNextPage: true,
},
});
expect(notificationsRepository.countUnread).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
});
it('creates system notifications with system resource mapping when requested', async () => {
const notificationsRepository = {
create: jest.fn().mockResolvedValue({ toJSON: () => ({ _id: 'notification-1' }) }),
countUnread: jest.fn().mockResolvedValue(1),
};
const notificationsGateway = {
emitCreated: jest.fn(),
};
const service = new NotificationsService(
notificationsRepository as any,
notificationsGateway as any,
);
await service.create({
actorId: '507f1f77bcf86cd799439011',
recipientId: '507f191e810c19729de860ea',
type: 'system',
});
expect(notificationsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
type: 'system',
resourceType: 'system',
deepLink: '',
}),
);
});
});

عرض الملف

@@ -206,10 +206,13 @@ export class NotificationsService {
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const unreadFilter = { ...filter };
delete unreadFilter.read;
const [items, total, unreadCount] = await Promise.all([
this.notificationsRepository.findMany(filter, skip, limit, sort),
this.notificationsRepository.count(filter),
this.notificationsRepository.countUnreadAll(filter),
this.notificationsRepository.countUnreadAll(unreadFilter),
]);
return {
@@ -250,13 +253,14 @@ export class NotificationsService {
}
async markAllRead(recipientId: string) {
const updatedCount = await this.notificationsRepository.markAllRead(recipientId);
const modifiedCount = await this.notificationsRepository.markAllRead(recipientId);
const unreadCount = await this.notificationsRepository.countUnread(recipientId);
this.notificationsGateway.emitUnreadCount(recipientId, unreadCount);
return {
message: 'All notifications marked as read',
updatedCount,
modifiedCount,
updatedCount: modifiedCount,
unreadCount,
};
}
@@ -277,6 +281,12 @@ export class NotificationsService {
return 'Post shared';
case 'mention':
return 'New mention';
case 'reply':
return 'New reply';
case 'system':
return 'Notification';
case 'collaboration_request':
return 'Collaboration request';
default:
return 'Notification';
}
@@ -288,6 +298,10 @@ export class NotificationsService {
return 'user';
case 'message':
return 'conversation';
case 'system':
return 'system';
case 'collaboration_request':
return 'post';
default:
return 'post';
}
@@ -306,8 +320,8 @@ export class NotificationsService {
return `/users/${referenceId}`;
}
if (resourceType === 'comment' && referenceId) {
return `/comments/${referenceId}`;
if (resourceType === 'system') {
return '';
}
if (referenceId) {

عرض الملف

@@ -4,7 +4,18 @@ import { User } from '../../users/schemas/user.schema';
export type NotificationDocument = HydratedDocument<Notification>;
export const NOTIFICATION_TYPES = ['like', 'comment', 'follow', 'message', 'save', 'share', 'mention'] as const;
export const NOTIFICATION_TYPES = [
'like',
'comment',
'follow',
'message',
'save',
'share',
'mention',
'reply',
'system',
'collaboration_request',
] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
@Schema({ timestamps: true, versionKey: false })
@@ -47,4 +58,6 @@ export const NotificationSchema = SchemaFactory.createForClass(Notification);
NotificationSchema.index({ recipientId: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, read: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, read: 1, type: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, type: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, resourceType: 1, createdAt: -1 });
NotificationSchema.index({ referenceId: 1 });

عرض الملف

@@ -0,0 +1,24 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, 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 { PostQueryDto } from './dto/post-query.dto';
import { PostsService } from './posts.service';
@ApiTags('Users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('users')
export class PostsUsersController {
constructor(private readonly postsService: PostsService) {}
@Get(':userId/posts')
async findUserPosts(
@CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PostQueryDto,
) {
return this.postsService.findUserPosts(userId, query, user.sub);
}
}

عرض الملف

@@ -106,8 +106,12 @@ export class PostsController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('user/:userId')
async findUserPosts(@Param('userId') userId: string, @Query() query: PostQueryDto) {
return this.postsService.findUserPosts(userId, query);
async findUserPosts(
@CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PostQueryDto,
) {
return this.postsService.findUserPosts(userId, query, user.sub);
}
@ApiBearerAuth()

عرض الملف

@@ -5,6 +5,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { UsersModule } from '../users/users.module';
import { Post, PostSchema } from './schemas/post.schema';
import { PostsController } from './posts.controller';
import { PostsUsersController } from './posts-users.controller';
import { PostsRepository } from './posts.repository';
import { PostsService } from './posts.service';
@@ -20,7 +21,7 @@ import { PostsService } from './posts.service';
NotificationsModule,
UsersModule,
],
controllers: [PostsController],
controllers: [PostsController, PostsUsersController],
providers: [PostsService, PostsRepository],
exports: [PostsService, PostsRepository],
})

عرض الملف

@@ -1,6 +1,7 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { extname } from 'path';
import { Types } from 'mongoose';
import { Connection, Types } from 'mongoose';
import { InjectConnection } from '@nestjs/mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { PostType } from '../../common/enums/post-type.enum';
import { PostVisibility } from '../../common/enums/post-visibility.enum';
@@ -72,6 +73,7 @@ export class PostsService {
private readonly logger = new Logger(PostsService.name);
constructor(
@InjectConnection() private readonly connection: Connection,
private readonly postsRepository: PostsRepository,
private readonly usersRepository: UsersRepository,
private readonly storageService: ManagedStorageService,
@@ -525,7 +527,7 @@ export class PostsService {
return post;
}
async findUserPosts(userId: string, query: PostQueryDto) {
async findUserPosts(userId: string, query: PostQueryDto, viewerUserId?: string) {
if (!Types.ObjectId.isValid(userId)) {
throw new BadRequestException('Invalid user id');
}
@@ -538,6 +540,21 @@ export class PostsService {
authorId: new Types.ObjectId(userId),
isArchived: { $ne: true },
};
if (viewerUserId && viewerUserId !== userId) {
const isBlocked = await this.hasBlockBetween(viewerUserId, userId);
if (isBlocked) {
return buildPaginatedResponse([], {
page,
limit,
total: 0,
offset: skip,
});
}
const isFollowing = await this.isFollowing(viewerUserId, userId);
filter.visibility = isFollowing
? { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] }
: PostVisibility.PUBLIC;
}
if (query.visibility) {
filter.visibility = query.visibility;
}
@@ -1602,4 +1619,30 @@ export class PostsService {
return '';
}
private async isFollowing(followerId: string, followingId: string): Promise<boolean> {
if (!Types.ObjectId.isValid(followerId) || !Types.ObjectId.isValid(followingId)) {
return false;
}
const count = await this.connection.collection('follows').countDocuments({
followerId: new Types.ObjectId(followerId),
followingId: new Types.ObjectId(followingId),
});
return count > 0;
}
private async hasBlockBetween(userA: string, userB: string): Promise<boolean> {
if (!Types.ObjectId.isValid(userA) || !Types.ObjectId.isValid(userB)) {
return false;
}
const userAId = new Types.ObjectId(userA);
const userBId = new Types.ObjectId(userB);
const count = await this.connection.collection('blocks').countDocuments({
$or: [
{ blockerId: userAId, blockedId: userBId },
{ blockerId: userBId, blockedId: userAId },
],
});
return count > 0;
}
}

عرض الملف

@@ -99,6 +99,12 @@ export class User {
@Prop({ default: false })
isEmailVerified!: boolean;
@Prop({ default: false, index: true })
isOnline!: boolean;
@Prop({ type: Date, default: null })
lastSeenAt?: Date | null;
@Prop({ default: '', trim: true, maxlength: 120 })
shopName!: string;

عرض الملف

@@ -288,11 +288,18 @@ export class UsersController {
return this.usersService.getProfileOverview(id, user.sub);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':id/presence')
async getPresence(@Param('id') id: string) {
return this.usersService.getPresence(id);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':id')
async findOne(@Param('id') id: string) {
return this.usersService.findPublicByIdOrFail(id);
async findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.usersService.findPublicByIdForViewer(id, user.sub);
}
@ApiBearerAuth()

عرض الملف

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ClientSession, FilterQuery, Model, UpdateQuery } from 'mongoose';
import { ClientSession, FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
import { User, UserDocument } from './schemas/user.schema';
@Injectable()
@@ -69,6 +69,12 @@ export class UsersRepository {
await this.userModel.findByIdAndUpdate(userId, { followingCount }, { new: false }).exec();
}
async setPresence(userId: string, isOnline: boolean, lastSeenAt: Date): Promise<void> {
await this.userModel
.findByIdAndUpdate(userId, { isOnline, lastSeenAt }, { new: false })
.exec();
}
async findMany(
filter: FilterQuery<UserDocument>,
skip: number,
@@ -78,6 +84,16 @@ export class UsersRepository {
return this.userModel.find(filter).sort(sort).skip(skip).limit(limit).exec();
}
async findManyByIds(ids: string[]): Promise<UserDocument[]> {
if (!ids.length) {
return [];
}
return this.userModel
.find({ _id: { $in: ids.map((id) => new Types.ObjectId(id)) } })
.exec();
}
async findByUsernames(usernames: string[]): Promise<UserDocument[]> {
if (!usernames.length) {
return [];

عرض الملف

@@ -237,6 +237,40 @@ export class UsersService {
return user;
}
async findPublicByIdForViewer(userId: string, viewerUserId: string) {
const user = await this.findPublicByIdOrFail(userId);
const isFollowing =
viewerUserId === userId
? false
: (await this.connection.collection('follows').countDocuments({
followerId: new Types.ObjectId(viewerUserId),
followingId: new Types.ObjectId(userId),
})) > 0;
return {
...user.toObject(),
isFollowing,
following: isFollowing,
isOwnProfile: viewerUserId === userId,
};
}
async getPresence(userId: string) {
const user = await this.findPublicByIdOrFail(userId);
return {
userId: user.id,
isOnline: user.isOnline ?? false,
lastSeenAt: user.lastSeenAt ?? null,
};
}
async setPresence(userId: string, isOnline: boolean): Promise<void> {
if (!Types.ObjectId.isValid(userId)) {
return;
}
await this.usersRepository.setPresence(userId, isOnline, new Date());
}
async findByEmailWithPassword(email: string): Promise<UserDocument | null> {
return this.usersRepository.findOneWithPassword({ email: email.toLowerCase() });
}
@@ -737,6 +771,7 @@ export class UsersService {
viewerState: {
isOwnProfile: viewerUserId === userId,
following: followingState,
isFollowing: followingState,
canMessage: viewerUserId !== userId,
},
};
@@ -749,6 +784,7 @@ export class UsersService {
viewerState: {
isOwnProfile: false,
following: false,
isFollowing: false,
canMessage: false,
},
};