feat: stabilize social backend contracts

This commit is contained in:
boutmoun123
2026-05-31 16:13:23 +03:00
parent ad6da6754d
commit 49e132909e
40 changed files with 12037 additions and 9562 deletions
+70
View File
@@ -13,6 +13,28 @@ export const apiEndpoints = {
revokeSuperAdminSession: (sessionId: string) => `/auth/superadmin/sessions/${sessionId}/revoke`, revokeSuperAdminSession: (sessionId: string) => `/auth/superadmin/sessions/${sessionId}/revoke`,
}, },
users: { 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> = {}) => all: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/users/admin?${toQueryString(params)}`, `/users/admin?${toQueryString(params)}`,
byId: (userId: string) => `/users/admin/${userId}`, byId: (userId: string) => `/users/admin/${userId}`,
@@ -31,6 +53,24 @@ export const apiEndpoints = {
profileOverview: (userId: string) => `/users/admin/${userId}/profile-overview`, profileOverview: (userId: string) => `/users/admin/${userId}/profile-overview`,
}, },
posts: { 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> = {}) => moderation: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/posts/admin/moderation?${toQueryString(params)}`, `/posts/admin/moderation?${toQueryString(params)}`,
adminDelete: (postId: string) => `/posts/admin/${postId}`, adminDelete: (postId: string) => `/posts/admin/${postId}`,
@@ -41,9 +81,39 @@ export const apiEndpoints = {
adminDelete: (commentId: string) => `/comments/admin/${commentId}`, adminDelete: (commentId: string) => `/comments/admin/${commentId}`,
}, },
notifications: { 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> = {}) => superAdmin: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/notifications/superadmin?${toQueryString(params)}`, `/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: { reports: {
superAdmin: (params: Record<string, string | number | boolean | null | undefined> = {}) => superAdmin: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
`/reports/superadmin?${toQueryString(params)}`, `/reports/superadmin?${toQueryString(params)}`,
+173
View File
@@ -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": [ "notes": [
"This dashboard must authenticate with /auth/superadmin/* routes only.", "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.", "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": { "modules": {
"auth": [ "auth": [
@@ -15,6 +16,22 @@
"POST /auth/superadmin/sessions/:sessionId/revoke" "POST /auth/superadmin/sessions/:sessionId/revoke"
], ],
"users": [ "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",
"GET /users/admin/admins", "GET /users/admin/admins",
"GET /users/admin/:userId", "GET /users/admin/:userId",
@@ -31,6 +48,23 @@
"PATCH /superadmin/users/:userId/status" "PATCH /superadmin/users/:userId/status"
], ],
"posts": [ "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", "GET /posts/admin/moderation",
"DELETE /posts/admin/:postId", "DELETE /posts/admin/:postId",
"PATCH /superadmin/posts/:postId/status" "PATCH /superadmin/posts/:postId/status"
@@ -41,8 +75,32 @@
"PATCH /superadmin/comments/:commentId/status" "PATCH /superadmin/comments/:commentId/status"
], ],
"notifications": [ "notifications": [
"GET /notifications",
"GET /notifications/unread-count",
"PATCH /notifications/:notificationId/read",
"PATCH /notifications/read-all",
"GET /notifications/superadmin" "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": [ "marketplace": [
"GET /marketplace/home", "GET /marketplace/home",
"GET /marketplace/listings", "GET /marketplace/listings",
+65 -1
View File
@@ -210,8 +210,69 @@ export type SaveStatusResponse = {
export type FollowStatusResponse = { export type FollowStatusResponse = {
following?: boolean; following?: boolean;
isFollowing?: 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 = export type NotificationType =
| "like" | "like"
| "comment" | "comment"
@@ -219,7 +280,10 @@ export type NotificationType =
| "message" | "message"
| "save" | "save"
| "share" | "share"
| "mention"; | "mention"
| "reply"
| "system"
| "collaboration_request";
export type NotificationItem = { export type NotificationItem = {
_id: string; _id: string;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -16,6 +16,8 @@ import { AuditModule } from './modules/audit/audit.module';
import { BlocksModule } from './modules/blocks/blocks.module'; import { BlocksModule } from './modules/blocks/blocks.module';
import { ChatModule } from './modules/chat/chat.module'; import { ChatModule } from './modules/chat/chat.module';
import { CommentsModule } from './modules/comments/comments.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 { FeedModule } from './modules/feed/feed.module';
import { FollowsModule } from './modules/follows/follows.module'; import { FollowsModule } from './modules/follows/follows.module';
import { LikesModule } from './modules/likes/likes.module'; import { LikesModule } from './modules/likes/likes.module';
@@ -50,6 +52,8 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
AuthModule, AuthModule,
PostsModule, PostsModule,
CommentsModule, CommentsModule,
CollaborationRequestsModule,
DevicesModule,
LikesModule, LikesModule,
FollowsModule, FollowsModule,
FeedModule, FeedModule,
@@ -6,4 +6,7 @@ export enum NotificationType {
SAVE = 'save', SAVE = 'save',
SHARE = 'share', SHARE = 'share',
MENTION = 'mention', MENTION = 'mention',
REPLY = 'reply',
SYSTEM = 'system',
COLLABORATION_REQUEST = 'collaboration_request',
} }
+24 -1
View File
@@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io';
import { ChatRealtimeService } from './chat-realtime.service'; import { ChatRealtimeService } from './chat-realtime.service';
import { ChatService } from './chat.service'; import { ChatService } from './chat.service';
import { SendMessageDto } from './dto/send-message.dto'; import { SendMessageDto } from './dto/send-message.dto';
import { UsersService } from '../users/users.service';
type SocketWithUser = Socket & { data: { userId?: string } }; type SocketWithUser = Socket & { data: { userId?: string } };
@@ -21,12 +22,14 @@ type SocketWithUser = Socket & { data: { userId?: string } };
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() @WebSocketServer()
server!: Server; server!: Server;
private readonly connectionCountsByUser = new Map<string, number>();
constructor( constructor(
private readonly chatService: ChatService, private readonly chatService: ChatService,
private readonly chatRealtimeService: ChatRealtimeService, private readonly chatRealtimeService: ChatRealtimeService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {} ) {}
afterInit(server: Server) { afterInit(server: Server) {
@@ -49,6 +52,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return; return;
} }
client.data.userId = payload.sub; client.data.userId = payload.sub;
this.incrementUserConnection(payload.sub);
await this.usersService.setPresence(payload.sub, true);
await client.join(this.userRoom(payload.sub)); await client.join(this.userRoom(payload.sub));
this.server.to(this.userRoom(payload.sub)).emit('presence', { userId: payload.sub, online: true }); this.server.to(this.userRoom(payload.sub)).emit('presence', { userId: payload.sub, online: true });
} catch { } catch {
@@ -59,7 +64,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
handleDisconnect(client: SocketWithUser) { handleDisconnect(client: SocketWithUser) {
const userId = client.data.userId; const userId = client.data.userId;
if (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 { private conversationRoom(conversationId: string): string {
return `conversation:${conversationId}`; 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 });
+26
View File
@@ -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);
}
}
+14
View File
@@ -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 {}
+45
View File
@@ -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();
}
}
+45
View File
@@ -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);
}
}
+12 -4
View File
@@ -24,15 +24,23 @@ export class FollowsController {
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('followers/:userId') @Get('followers/:userId')
async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) { async followers(
return this.followsService.getFollowers(userId, query); @CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PaginationQueryDto,
) {
return this.followsService.getFollowers(userId, query, user.sub);
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('following/:userId') @Get('following/:userId')
async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) { async following(
return this.followsService.getFollowing(userId, query); @CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PaginationQueryDto,
) {
return this.followsService.getFollowing(userId, query, user.sub);
} }
@ApiBearerAuth() @ApiBearerAuth()
+4 -1
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModule } from '@nestjs/mongoose';
import { BlocksModule } from '../blocks/blocks.module';
import { OutboxModule } from '../outbox/outbox.module'; import { OutboxModule } from '../outbox/outbox.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { FollowsController } from './follows.controller'; import { FollowsController } from './follows.controller';
import { FollowsUsersController } from './follows-users.controller';
import { FollowsService } from './follows.service'; import { FollowsService } from './follows.service';
import { FollowsRepository } from './follows.repository'; import { FollowsRepository } from './follows.repository';
import { FollowRequest, FollowRequestSchema } from './schemas/follow-request.schema'; import { FollowRequest, FollowRequestSchema } from './schemas/follow-request.schema';
@@ -10,6 +12,7 @@ import { Follow, FollowSchema } from './schemas/follow.schema';
@Module({ @Module({
imports: [ imports: [
BlocksModule,
UsersModule, UsersModule,
OutboxModule, OutboxModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
@@ -23,7 +26,7 @@ import { Follow, FollowSchema } from './schemas/follow.schema';
}, },
]), ]),
], ],
controllers: [FollowsController], controllers: [FollowsController, FollowsUsersController],
providers: [FollowsService, FollowsRepository], providers: [FollowsService, FollowsRepository],
exports: [FollowsService, FollowsRepository], exports: [FollowsService, FollowsRepository],
}) })
+31
View File
@@ -39,6 +39,15 @@ export class FollowsRepository {
return follow; 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> { async deleteById(id: string, session?: ClientSession): Promise<void> {
await this.followModel.findByIdAndDelete(id, { session }).exec(); await this.followModel.findByIdAndDelete(id, { session }).exec();
} }
@@ -83,6 +92,28 @@ export class FollowsRepository {
.exec(); .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> { async upsertPendingRequest(requesterId: string, targetUserId: string): Promise<FollowRequestDocument> {
return this.followRequestModel return this.followRequestModel
.findOneAndUpdate( .findOneAndUpdate(
@@ -21,12 +21,16 @@ describe('FollowsService', () => {
const outboxService = { const outboxService = {
enqueueFollowNotification: jest.fn().mockRejectedValue(new Error('socket down')), enqueueFollowNotification: jest.fn().mockRejectedValue(new Error('socket down')),
}; };
const blocksRepository = {
findAnyBetween: jest.fn().mockResolvedValue(null),
};
const service = new FollowsService( const service = new FollowsService(
followsRepository as any, followsRepository as any,
usersRepository as any, usersRepository as any,
outboxService as any, outboxService as any,
{ bumpGlobalVersion: jest.fn().mockResolvedValue(1) } as any, { bumpGlobalVersion: jest.fn().mockResolvedValue(1) } as any,
blocksRepository as any,
); );
await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({ await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({
@@ -40,4 +44,51 @@ describe('FollowsService', () => {
'follow-1', '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();
});
}); });
+208 -24
View File
@@ -4,6 +4,7 @@ import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { BlocksRepository } from '../blocks/blocks.repository';
import { OutboxService } from '../outbox/outbox.service'; import { OutboxService } from '../outbox/outbox.service';
import { UsersRepository } from '../users/users.repository'; import { UsersRepository } from '../users/users.repository';
import { UserDocument } from '../users/schemas/user.schema'; import { UserDocument } from '../users/schemas/user.schema';
@@ -19,23 +20,19 @@ export class FollowsService {
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
private readonly outboxService: OutboxService, private readonly outboxService: OutboxService,
private readonly feedVersionService: FeedVersionService, private readonly feedVersionService: FeedVersionService,
private readonly blocksRepository: BlocksRepository,
) {} ) {}
async toggleFollow(currentUserId: string, dto: ToggleFollowDto) { async toggleFollow(currentUserId: string, dto: ToggleFollowDto) {
const targetUserId = dto.targetUserId; const targetUserId = dto.targetUserId;
if (!Types.ObjectId.isValid(targetUserId)) { if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id'); throw new BadRequestException('Invalid target user id');
} }
if (currentUserId === targetUserId) { if (currentUserId === targetUserId) {
throw new BadRequestException('You cannot follow yourself'); throw new BadRequestException('You cannot follow yourself');
} }
const targetUser = await this.usersRepository.findById(targetUserId); const targetUser = await this.findActiveTargetUser(targetUserId);
if (!targetUser) {
throw new NotFoundException('Target user not found');
}
const existing = await this.followsRepository.findOne(currentUserId, targetUserId); const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
@@ -46,37 +43,107 @@ export class FollowsService {
return { following: false }; return { following: false };
} }
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
if (block) {
throw new BadRequestException('You cannot follow this user');
}
if (targetUser.isPrivate) { if (targetUser.isPrivate) {
const request = await this.followsRepository.upsertPendingRequest(currentUserId, targetUserId); const request = await this.followsRepository.upsertPendingRequest(currentUserId, targetUserId);
return { following: false, requested: true, requestId: request.id }; 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.syncFollowCounts(currentUserId, targetUserId);
await this.feedVersionService.bumpGlobalVersion(); await this.feedVersionService.bumpGlobalVersion();
try { if (followId) {
await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id); await this.enqueueFollowNotification(currentUserId, targetUserId, followId);
} catch (error) {
this.logger.warn(
`Follow notification failed for actor=${currentUserId} recipient=${targetUserId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
} }
return { following: true }; 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 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 sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>; const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([ const [followRows, total] = await Promise.all([
this.followsRepository.findMany({ followingId: userId }, skip, limit, sort), this.followsRepository.findFollowerUserIds(userId, skip, limit, sort),
this.followsRepository.count({ followingId: userId }), this.followsRepository.count({ followingId: userId }),
]); ]);
const items = await this.decorateFollowUsers(
followRows.map((row) => row.followerId.toString()),
viewerUserId,
);
return buildPaginatedResponse(items, { return buildPaginatedResponse(items, {
page, 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 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 sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>; const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([ const [followRows, total] = await Promise.all([
this.followsRepository.findMany({ followerId: userId }, skip, limit, sort), this.followsRepository.findFollowingUserIds(userId, skip, limit, sort),
this.followsRepository.count({ followerId: userId }), this.followsRepository.count({ followerId: userId }),
]); ]);
const items = await this.decorateFollowUsers(
followRows.map((row) => row.followingId.toString()),
viewerUserId,
);
return buildPaginatedResponse(items, { return buildPaginatedResponse(items, {
page, page,
@@ -110,16 +182,28 @@ export class FollowsService {
} }
if (currentUserId === targetUserId) { 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 = const pendingRequest =
currentUserId === targetUserId currentUserId === targetUserId
? null ? null
: await this.followsRepository.findPendingRequest(currentUserId, targetUserId); : await this.followsRepository.findPendingRequest(currentUserId, targetUserId);
return { return {
following: !!existing, following: !!existing,
isFollowing: !!existing,
isFollowedBy: !!reverseExisting,
isMutual: !!existing && !!reverseExisting,
requested: !!pendingRequest, requested: !!pendingRequest,
targetUserId, targetUserId,
}; };
@@ -282,7 +366,10 @@ export class FollowsService {
return count; 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([ const [followingCount, followersCount] = await Promise.all([
this.followsRepository.count({ followerId: currentUserId }), this.followsRepository.count({ followerId: currentUserId }),
this.followsRepository.count({ followingId: targetUserId }), this.followsRepository.count({ followingId: targetUserId }),
@@ -292,5 +379,102 @@ export class FollowsService {
this.usersRepository.setFollowingCount(currentUserId, followingCount), this.usersRepository.setFollowingCount(currentUserId, followingCount),
this.usersRepository.setFollowersCount(targetUserId, followersCount), 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); export const FollowSchema = SchemaFactory.createForClass(Follow);
FollowSchema.index({ followerId: 1, followingId: 1 }, { unique: true }); 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> { async markRead(recipientId: string, notificationId: string): Promise<NotificationDocument | null> {
const updated = await this.notificationModel const notification = await this.notificationModel
.findOneAndUpdate( .findOne({
{ _id: new Types.ObjectId(notificationId),
_id: new Types.ObjectId(notificationId), recipientId: new Types.ObjectId(recipientId),
recipientId: new Types.ObjectId(recipientId), })
},
{
read: true,
readAt: new Date(),
},
{ new: true },
)
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
.exec(); .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> { async markAllRead(recipientId: string): Promise<number> {
@@ -47,6 +47,7 @@ describe('NotificationsService', () => {
await expect(service.markAllRead('user-1')).resolves.toEqual({ await expect(service.markAllRead('user-1')).resolves.toEqual({
message: 'All notifications marked as read', message: 'All notifications marked as read',
modifiedCount: 4,
updatedCount: 4, updatedCount: 4,
unreadCount: 2, unreadCount: 2,
}); });
@@ -70,4 +71,68 @@ describe('NotificationsService', () => {
await expect(service.markRead('user-1', 'invalid-id')).rejects.toBeInstanceOf(NotFoundException); await expect(service.markRead('user-1', 'invalid-id')).rejects.toBeInstanceOf(NotFoundException);
expect(notificationsRepository.markRead).not.toHaveBeenCalled(); 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 sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const unreadFilter = { ...filter };
delete unreadFilter.read;
const [items, total, unreadCount] = await Promise.all([ const [items, total, unreadCount] = await Promise.all([
this.notificationsRepository.findMany(filter, skip, limit, sort), this.notificationsRepository.findMany(filter, skip, limit, sort),
this.notificationsRepository.count(filter), this.notificationsRepository.count(filter),
this.notificationsRepository.countUnreadAll(filter), this.notificationsRepository.countUnreadAll(unreadFilter),
]); ]);
return { return {
@@ -250,13 +253,14 @@ export class NotificationsService {
} }
async markAllRead(recipientId: string) { 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); const unreadCount = await this.notificationsRepository.countUnread(recipientId);
this.notificationsGateway.emitUnreadCount(recipientId, unreadCount); this.notificationsGateway.emitUnreadCount(recipientId, unreadCount);
return { return {
message: 'All notifications marked as read', message: 'All notifications marked as read',
updatedCount, modifiedCount,
updatedCount: modifiedCount,
unreadCount, unreadCount,
}; };
} }
@@ -277,6 +281,12 @@ export class NotificationsService {
return 'Post shared'; return 'Post shared';
case 'mention': case 'mention':
return 'New mention'; return 'New mention';
case 'reply':
return 'New reply';
case 'system':
return 'Notification';
case 'collaboration_request':
return 'Collaboration request';
default: default:
return 'Notification'; return 'Notification';
} }
@@ -288,6 +298,10 @@ export class NotificationsService {
return 'user'; return 'user';
case 'message': case 'message':
return 'conversation'; return 'conversation';
case 'system':
return 'system';
case 'collaboration_request':
return 'post';
default: default:
return 'post'; return 'post';
} }
@@ -306,8 +320,8 @@ export class NotificationsService {
return `/users/${referenceId}`; return `/users/${referenceId}`;
} }
if (resourceType === 'comment' && referenceId) { if (resourceType === 'system') {
return `/comments/${referenceId}`; return '';
} }
if (referenceId) { if (referenceId) {
@@ -4,7 +4,18 @@ import { User } from '../../users/schemas/user.schema';
export type NotificationDocument = HydratedDocument<Notification>; 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]; export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
@Schema({ timestamps: true, versionKey: false }) @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, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, read: 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, read: 1, type: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, type: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, resourceType: 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);
}
}
+6 -2
View File
@@ -106,8 +106,12 @@ export class PostsController {
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('user/:userId') @Get('user/:userId')
async findUserPosts(@Param('userId') userId: string, @Query() query: PostQueryDto) { async findUserPosts(
return this.postsService.findUserPosts(userId, query); @CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Query() query: PostQueryDto,
) {
return this.postsService.findUserPosts(userId, query, user.sub);
} }
@ApiBearerAuth() @ApiBearerAuth()
+2 -1
View File
@@ -5,6 +5,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { Post, PostSchema } from './schemas/post.schema'; import { Post, PostSchema } from './schemas/post.schema';
import { PostsController } from './posts.controller'; import { PostsController } from './posts.controller';
import { PostsUsersController } from './posts-users.controller';
import { PostsRepository } from './posts.repository'; import { PostsRepository } from './posts.repository';
import { PostsService } from './posts.service'; import { PostsService } from './posts.service';
@@ -20,7 +21,7 @@ import { PostsService } from './posts.service';
NotificationsModule, NotificationsModule,
UsersModule, UsersModule,
], ],
controllers: [PostsController], controllers: [PostsController, PostsUsersController],
providers: [PostsService, PostsRepository], providers: [PostsService, PostsRepository],
exports: [PostsService, PostsRepository], exports: [PostsService, PostsRepository],
}) })
+45 -2
View File
@@ -1,6 +1,7 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { extname } from 'path'; 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 { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { PostType } from '../../common/enums/post-type.enum'; import { PostType } from '../../common/enums/post-type.enum';
import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum';
@@ -72,6 +73,7 @@ export class PostsService {
private readonly logger = new Logger(PostsService.name); private readonly logger = new Logger(PostsService.name);
constructor( constructor(
@InjectConnection() private readonly connection: Connection,
private readonly postsRepository: PostsRepository, private readonly postsRepository: PostsRepository,
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
private readonly storageService: ManagedStorageService, private readonly storageService: ManagedStorageService,
@@ -525,7 +527,7 @@ export class PostsService {
return post; return post;
} }
async findUserPosts(userId: string, query: PostQueryDto) { async findUserPosts(userId: string, query: PostQueryDto, viewerUserId?: string) {
if (!Types.ObjectId.isValid(userId)) { if (!Types.ObjectId.isValid(userId)) {
throw new BadRequestException('Invalid user id'); throw new BadRequestException('Invalid user id');
} }
@@ -538,6 +540,21 @@ export class PostsService {
authorId: new Types.ObjectId(userId), authorId: new Types.ObjectId(userId),
isArchived: { $ne: true }, 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) { if (query.visibility) {
filter.visibility = query.visibility; filter.visibility = query.visibility;
} }
@@ -1602,4 +1619,30 @@ export class PostsService {
return ''; 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;
}
} }
+6
View File
@@ -99,6 +99,12 @@ export class User {
@Prop({ default: false }) @Prop({ default: false })
isEmailVerified!: boolean; isEmailVerified!: boolean;
@Prop({ default: false, index: true })
isOnline!: boolean;
@Prop({ type: Date, default: null })
lastSeenAt?: Date | null;
@Prop({ default: '', trim: true, maxlength: 120 }) @Prop({ default: '', trim: true, maxlength: 120 })
shopName!: string; shopName!: string;
+9 -2
View File
@@ -288,11 +288,18 @@ export class UsersController {
return this.usersService.getProfileOverview(id, user.sub); 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() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get(':id') @Get(':id')
async findOne(@Param('id') id: string) { async findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.usersService.findPublicByIdOrFail(id); return this.usersService.findPublicByIdForViewer(id, user.sub);
} }
@ApiBearerAuth() @ApiBearerAuth()
+17 -1
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; 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'; import { User, UserDocument } from './schemas/user.schema';
@Injectable() @Injectable()
@@ -69,6 +69,12 @@ export class UsersRepository {
await this.userModel.findByIdAndUpdate(userId, { followingCount }, { new: false }).exec(); 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( async findMany(
filter: FilterQuery<UserDocument>, filter: FilterQuery<UserDocument>,
skip: number, skip: number,
@@ -78,6 +84,16 @@ export class UsersRepository {
return this.userModel.find(filter).sort(sort).skip(skip).limit(limit).exec(); 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[]> { async findByUsernames(usernames: string[]): Promise<UserDocument[]> {
if (!usernames.length) { if (!usernames.length) {
return []; return [];
+36
View File
@@ -237,6 +237,40 @@ export class UsersService {
return user; 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> { async findByEmailWithPassword(email: string): Promise<UserDocument | null> {
return this.usersRepository.findOneWithPassword({ email: email.toLowerCase() }); return this.usersRepository.findOneWithPassword({ email: email.toLowerCase() });
} }
@@ -737,6 +771,7 @@ export class UsersService {
viewerState: { viewerState: {
isOwnProfile: viewerUserId === userId, isOwnProfile: viewerUserId === userId,
following: followingState, following: followingState,
isFollowing: followingState,
canMessage: viewerUserId !== userId, canMessage: viewerUserId !== userId,
}, },
}; };
@@ -749,6 +784,7 @@ export class UsersService {
viewerState: { viewerState: {
isOwnProfile: false, isOwnProfile: false,
following: false, following: false,
isFollowing: false,
canMessage: false, canMessage: false,
}, },
}; };