diff --git a/src/modules/feed/feed.service.ts b/src/modules/feed/feed.service.ts index e7a2111..618fffc 100644 --- a/src/modules/feed/feed.service.ts +++ b/src/modules/feed/feed.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Types } from 'mongoose'; +import { InjectConnection } from '@nestjs/mongoose'; +import { Connection, Types } from 'mongoose'; import { performance } from 'perf_hooks'; import { PostType } from '../../common/enums/post-type.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum'; @@ -12,6 +13,7 @@ import { BlocksService } from '../blocks/blocks.service'; import { FollowsService } from '../follows/follows.service'; import { LikesRepository } from '../likes/likes.repository'; import { MarketplaceService } from '../marketplace/marketplace.service'; +import { PostShareTarget } from '../posts/dto/share-post.dto'; import { SavesRepository } from '../saves/saves.repository'; import { UserDocument } from '../users/schemas/user.schema'; import { UsersRepository } from '../users/users.repository'; @@ -23,6 +25,7 @@ type FeedPostItem = Record & { feedScore?: number; likedByMe: boolean; savedByMe: boolean; + isSharedByMe: boolean; followingAuthor: boolean; isOwnPost: boolean; canComment: boolean; @@ -71,6 +74,7 @@ export class FeedService { private readonly followsService: FollowsService, private readonly marketplaceService: MarketplaceService, private readonly blocksService: BlocksService, + @InjectConnection() private readonly connection: Connection, ) {} async getMyFeed(currentUserId: string, query: FeedQueryDto) { @@ -333,12 +337,14 @@ export class FeedService { .map((item) => this.extractEntityId(item._id ?? item.id)) .filter(Boolean); const followingSet = new Set(followingIds); - const [likedPostIds, savedPostIds] = await Promise.all([ + const [likedPostIds, savedPostIds, sharedPostIds] = await Promise.all([ this.likesRepository.findLikedPostIds(currentUserId, postIds), this.savesRepository.findSavedPostIds(currentUserId, postIds), + this.findSharedPostIds(currentUserId, postIds), ]); const likedSet = new Set(likedPostIds); const savedSet = new Set(savedPostIds); + const sharedSet = new Set(sharedPostIds); return items.map((item) => { const postId = this.extractEntityId(item._id ?? item.id); @@ -356,6 +362,7 @@ export class FeedService { feedItemType: 'post', likedByMe: likedSet.has(postId), savedByMe: savedSet.has(postId), + isSharedByMe: sharedSet.has(postId), followingAuthor: !!authorId && followingSet.has(authorId), isOwnPost: authorId === currentUserId, canComment: true, @@ -372,6 +379,31 @@ export class FeedService { }); } + private async findSharedPostIds(currentUserId: string, postIds: string[]): Promise { + if (!Types.ObjectId.isValid(currentUserId) || postIds.length === 0) { + return []; + } + + const validPostIds = Array.from(new Set(postIds.filter((id) => Types.ObjectId.isValid(id)))); + if (!validPostIds.length) { + return []; + } + + const rows = await this.connection + .collection<{ postId: Types.ObjectId }>('postshares') + .find({ + userId: new Types.ObjectId(currentUserId), + postId: { $in: validPostIds.map((id) => new Types.ObjectId(id)) }, + target: PostShareTarget.PROFILE, + isDeleted: false, + deletedAt: null, + }) + .project({ postId: 1 }) + .toArray(); + + return rows.map((row) => row.postId.toString()); + } + private async mixHomeFeedItems( currentUserId: string, posts: FeedPostItem[], diff --git a/src/modules/posts/posts.controller.ts b/src/modules/posts/posts.controller.ts index 864b461..a3cec42 100644 --- a/src/modules/posts/posts.controller.ts +++ b/src/modules/posts/posts.controller.ts @@ -183,8 +183,8 @@ export class PostsController { @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Get(':postId') - async findById(@Param('postId') postId: string) { - return this.postsService.findById(postId); + async findById(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + return this.postsService.findById(postId, user.sub); } @ApiBearerAuth() @@ -394,4 +394,12 @@ export class PostsController { ) { return this.postsService.registerShare(user.sub, postId, dto); } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Delete(':postId/share') + async unshare(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + return this.postsService.unsharePost(user.sub, postId); + } } diff --git a/src/modules/posts/posts.module.ts b/src/modules/posts/posts.module.ts index 2da9982..8c176ec 100644 --- a/src/modules/posts/posts.module.ts +++ b/src/modules/posts/posts.module.ts @@ -7,6 +7,7 @@ import { FollowsModule } from '../follows/follows.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { UsersModule } from '../users/users.module'; import { Post, PostSchema } from './schemas/post.schema'; +import { PostShare, PostShareSchema } from './schemas/post-share.schema'; import { PostsController } from './posts.controller'; import { PostsUsersController } from './posts-users.controller'; import { PostsRepository } from './posts.repository'; @@ -19,6 +20,10 @@ import { PostsService } from './posts.service'; name: Post.name, schema: PostSchema, }, + { + name: PostShare.name, + schema: PostShareSchema, + }, ]), AuditModule, BlocksModule, diff --git a/src/modules/posts/posts.service.spec.ts b/src/modules/posts/posts.service.spec.ts index 0e3647b..6295dd5 100644 --- a/src/modules/posts/posts.service.spec.ts +++ b/src/modules/posts/posts.service.spec.ts @@ -23,13 +23,21 @@ const createService = (options: { shareBaseUrl?: string; publicWebUrl?: string } updateById: jest.fn(), create: jest.fn(), incrementShareCount: jest.fn().mockResolvedValue(undefined), + findManyByIds: jest.fn().mockResolvedValue([]), }; const connection = { collection: jest.fn(() => collectionMock), }; const collectionMock = { countDocuments: jest.fn().mockResolvedValue(0), + findOne: jest.fn().mockResolvedValue(null), + findOneAndUpdate: jest.fn().mockResolvedValue(null), insertOne: jest.fn().mockResolvedValue({ insertedId: new Types.ObjectId() }), + find: jest.fn(() => ({ + sort: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([]), + })), }; const usersRepository = { findById: jest.fn().mockResolvedValue({ @@ -164,6 +172,123 @@ describe('PostsService archived profile posts', () => { expect.any(Object), ); }); + + it('returns original posts and shared_post items in one profile timeline', async () => { + const userId = new Types.ObjectId().toString(); + const viewerId = new Types.ObjectId().toString(); + const authorId = new Types.ObjectId().toString(); + const ownPostId = new Types.ObjectId().toString(); + const originalPostId = new Types.ObjectId().toString(); + const shareId = new Types.ObjectId(); + const { service, postsRepository, usersRepository, collectionMock } = createService(); + const ownPost = { + id: ownPostId, + _id: new Types.ObjectId(ownPostId), + authorId: new Types.ObjectId(userId), + content: 'Own post', + visibility: PostVisibility.PUBLIC, + postType: PostType.TEXT, + shareCount: 0, + isArchived: false, + moderationStatus: 'active', + createdAt: new Date('2026-07-03T10:00:00.000Z'), + }; + const originalPost = { + id: originalPostId, + _id: new Types.ObjectId(originalPostId), + authorId: { + _id: new Types.ObjectId(authorId), + id: authorId, + name: 'Original Author', + username: 'original', + avatar: '', + }, + content: 'Shared original', + visibility: PostVisibility.PUBLIC, + postType: PostType.TEXT, + shareCount: 2, + hashtags: [], + isArchived: false, + isDeleted: false, + moderationStatus: 'active', + createdAt: new Date('2026-07-03T09:00:00.000Z'), + toObject: () => ({ + id: originalPostId, + _id: new Types.ObjectId(originalPostId), + authorId: { + _id: new Types.ObjectId(authorId), + id: authorId, + name: 'Original Author', + username: 'original', + avatar: '', + }, + content: 'Shared original', + visibility: PostVisibility.PUBLIC, + postType: PostType.TEXT, + shareCount: 2, + createdAt: new Date('2026-07-03T09:00:00.000Z'), + }), + }; + const share = { + _id: shareId, + userId: new Types.ObjectId(userId), + postId: new Types.ObjectId(originalPostId), + originalAuthorId: new Types.ObjectId(authorId), + target: 'profile', + isDeleted: false, + deletedAt: null, + createdAt: new Date('2026-07-03T12:00:00.000Z'), + }; + const shareFindChain = { + sort: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([share]), + }; + const viewerShareFindChain = { + sort: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([{ postId: new Types.ObjectId(originalPostId) }]), + }; + collectionMock.countDocuments.mockImplementation((filter: Record) => + Promise.resolve(filter.target === 'profile' ? 1 : 0), + ); + collectionMock.find.mockReturnValueOnce(shareFindChain).mockReturnValueOnce(viewerShareFindChain); + postsRepository.count.mockResolvedValue(1); + postsRepository.findMany.mockResolvedValue([ownPost]); + postsRepository.findManyByIds.mockResolvedValue([originalPost]); + usersRepository.findById.mockImplementation((id: string) => + Promise.resolve({ + id, + isDisabled: false, + toObject: () => ({ + _id: new Types.ObjectId(id), + id, + name: id === userId ? 'Profile User' : 'Original Author', + username: id === userId ? 'profile' : 'original', + avatar: '', + }), + }), + ); + + const result = await service.findUserPosts(userId, { page: 1, limit: 20 }, viewerId); + + expect(result.total).toBe(2); + expect(result.items[0]).toEqual( + expect.objectContaining({ + id: shareId.toString(), + type: 'shared_post', + sharedAt: '2026-07-03T12:00:00.000Z', + sharedBy: expect.objectContaining({ id: userId, username: 'profile' }), + sharedFrom: expect.objectContaining({ id: authorId, username: 'original' }), + originalPost: expect.objectContaining({ + id: originalPostId, + type: 'post', + isSharedByMe: true, + }), + }), + ); + expect(result.items[1]).toEqual(expect.objectContaining({ id: ownPostId, type: 'post' })); + }); }); describe('PostsService content placement', () => { @@ -272,16 +397,163 @@ describe('PostsService post sharing', () => { expect(result.viewer).toEqual(expect.objectContaining({ id: viewerId, username: 'viewer' })); }); - it('keeps legacy POST /share behavior without a body', async () => { + it('creates a profile share record without a body', async () => { const viewerId = new Types.ObjectId().toString(); const post = buildPost(); - const { service, postsRepository } = createService(); + const { service, postsRepository, collectionMock } = createService(); postsRepository.findById.mockResolvedValue(post); const result = await service.registerShare(viewerId, post.id); expect(postsRepository.incrementShareCount).toHaveBeenCalledWith(post.id, 1); - expect(result).toEqual({ success: true, postId: post.id, shareCount: 5 }); + expect(collectionMock.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ + userId: new Types.ObjectId(viewerId), + postId: new Types.ObjectId(post.id), + target: 'profile', + isDeleted: false, + deletedAt: null, + }), + ); + expect(result).toEqual( + expect.objectContaining({ + message: 'Post shared successfully', + postId: post.id, + shareCount: 5, + isSharedByMe: true, + shareUrl: `https://oudelaa.com/posts/${post.id}`, + }), + ); + expect(result.shareId).toBeTruthy(); + }); + + it('returns shared_post from profile posts after POST /share creates a profile share', async () => { + const viewerId = new Types.ObjectId().toString(); + const authorId = new Types.ObjectId().toString(); + const post = buildPost(authorId); + const shareId = new Types.ObjectId(); + const { service, postsRepository, collectionMock, usersRepository } = createService(); + let insertedShare: any; + postsRepository.findById.mockResolvedValue(post); + collectionMock.insertOne.mockImplementation(async (payload: any) => { + insertedShare = { ...payload, _id: shareId }; + return { insertedId: shareId }; + }); + + const shareResult = await service.registerShare(viewerId, post.id); + + expect(shareResult.shareId).toBe(shareId.toString()); + expect(insertedShare).toEqual( + expect.objectContaining({ + userId: new Types.ObjectId(viewerId), + postId: new Types.ObjectId(post.id), + target: 'profile', + isDeleted: false, + deletedAt: null, + }), + ); + + const originalPost = { + ...post, + _id: new Types.ObjectId(post.id), + id: post.id, + authorId: { + _id: new Types.ObjectId(authorId), + id: authorId, + name: 'Original Author', + username: 'original', + avatar: '', + }, + postType: PostType.TEXT, + hashtags: [], + isDeleted: false, + createdAt: new Date('2026-07-03T09:00:00.000Z'), + toObject: () => ({ + ...post, + _id: new Types.ObjectId(post.id), + id: post.id, + authorId: { + _id: new Types.ObjectId(authorId), + id: authorId, + name: 'Original Author', + username: 'original', + avatar: '', + }, + postType: PostType.TEXT, + createdAt: new Date('2026-07-03T09:00:00.000Z'), + }), + }; + const profileShareFindChain = { + sort: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([insertedShare]), + }; + const viewerShareFindChain = { + sort: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([{ postId: new Types.ObjectId(post.id) }]), + }; + + collectionMock.countDocuments.mockImplementation((filter: Record) => + Promise.resolve(filter.target === 'profile' ? 1 : 0), + ); + collectionMock.find.mockReturnValueOnce(profileShareFindChain).mockReturnValueOnce(viewerShareFindChain); + postsRepository.count.mockResolvedValue(0); + postsRepository.findMany.mockResolvedValue([]); + postsRepository.findManyByIds.mockResolvedValue([originalPost]); + usersRepository.findById.mockImplementation((id: string) => + Promise.resolve({ + id, + isDisabled: false, + toObject: () => ({ + _id: new Types.ObjectId(id), + id, + name: id === viewerId ? 'Profile User' : 'Original Author', + username: id === viewerId ? 'profile' : 'original', + avatar: '', + }), + }), + ); + + const profileResult = await service.findUserPosts(viewerId, { page: 1, limit: 20 }, viewerId); + + expect(profileResult.total).toBe(1); + expect(profileResult.items).toEqual([ + expect.objectContaining({ + id: shareId.toString(), + type: 'shared_post', + originalPost: expect.objectContaining({ + id: post.id, + type: 'post', + isSharedByMe: true, + }), + }), + ]); + }); + + it('does not duplicate a profile share for the same user and post', async () => { + const viewerId = new Types.ObjectId().toString(); + const post = buildPost(); + const shareId = new Types.ObjectId(); + const { service, postsRepository, collectionMock } = createService(); + postsRepository.findById.mockResolvedValue(post); + collectionMock.findOne.mockResolvedValue({ + _id: shareId, + userId: new Types.ObjectId(viewerId), + postId: new Types.ObjectId(post.id), + target: 'profile', + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + }); + + const result = await service.registerShare(viewerId, post.id); + + expect(collectionMock.insertOne).not.toHaveBeenCalled(); + expect(postsRepository.incrementShareCount).not.toHaveBeenCalled(); + expect(result.shareId).toBe(shareId.toString()); + expect(result.shareCount).toBe(4); + expect(result.isSharedByMe).toBe(true); }); it('records copy_link shares and increments shareCount', async () => { @@ -303,6 +575,43 @@ describe('PostsService post sharing', () => { }), ); expect(result.shareCount).toBe(5); + expect(result.isSharedByMe).toBe(false); + }); + + it('soft deletes a profile share and decrements shareCount', async () => { + const viewerId = new Types.ObjectId().toString(); + const post = buildPost(); + const { service, postsRepository, collectionMock } = createService(); + postsRepository.findById.mockResolvedValue(post); + collectionMock.findOneAndUpdate.mockResolvedValue({ + _id: new Types.ObjectId(), + userId: new Types.ObjectId(viewerId), + postId: new Types.ObjectId(post.id), + target: 'profile', + isDeleted: true, + deletedAt: new Date(), + }); + + const result = await service.unsharePost(viewerId, post.id); + + expect(collectionMock.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + userId: new Types.ObjectId(viewerId), + postId: new Types.ObjectId(post.id), + target: 'profile', + }), + expect.objectContaining({ + $set: expect.objectContaining({ isDeleted: true }), + }), + { returnDocument: 'after' }, + ); + expect(postsRepository.incrementShareCount).toHaveBeenCalledWith(post.id, -1); + expect(result).toEqual({ + message: 'Post unshared successfully', + postId: post.id, + shareCount: 3, + isSharedByMe: false, + }); }); it('normalizes trailing slash in SHARE_BASE_URL when building post links', async () => { diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index dadf32b..b66c361 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -97,6 +97,18 @@ type SavedImageUpload = { variants: PostMediaVariantSet; }; +type ProfileShareRecord = { + _id: Types.ObjectId; + userId: Types.ObjectId; + postId: Types.ObjectId; + originalAuthorId?: Types.ObjectId; + sharedFrom?: { type?: string; id?: string; name?: string } | null; + createdAt?: Date; + updatedAt?: Date; + isDeleted?: boolean; + deletedAt?: Date | null; +}; + @Injectable() export class PostsService { private readonly logger = new Logger(PostsService.name); @@ -634,12 +646,15 @@ export class PostsService { await this.feedVersionService.bumpGlobalVersion(); } - async findById(postId: string): Promise { + async findById(postId: string, viewerUserId?: string): Promise> { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } - return post; + const sharedPostIds = viewerUserId + ? await this.findSharedPostIdSet(viewerUserId, [post.id]) + : new Set(); + return this.formatPostItem(post, sharedPostIds); } async findUserPosts(userId: string, query: PostQueryDto, viewerUserId?: string) { @@ -699,10 +714,71 @@ export class PostsService { const sortField = query.sortBy ?? 'createdAt'; const sort = { pinnedToProfile: -1, [sortField]: direction } as Record; - const [items, total] = await Promise.all([ - this.postsRepository.findMany(filter, skip, limit, sort), - this.postsRepository.count(filter), + const profileShareCount = archivedOnly ? 0 : await this.countActiveProfileShares(userId); + + if (profileShareCount === 0) { + const [items, total] = await Promise.all([ + this.postsRepository.findMany(filter, skip, limit, sort), + this.postsRepository.count(filter), + ]); + const sharedPostIds = viewerUserId + ? await this.findSharedPostIdSet(viewerUserId, items.map((item) => item.id)) + : new Set(); + + return buildPaginatedResponse( + items.map((item) => this.formatPostItem(item, sharedPostIds)), + { + page, + limit, + total, + offset: skip, + }, + ); + } + + const originalTotal = await this.postsRepository.count(filter); + const [originalPosts, profileShares] = await Promise.all([ + this.postsRepository.findMany(filter, 0, originalTotal, sort), + this.findActiveProfileShares(userId), ]); + const sharedPostIds = profileShares.map((share) => share.postId.toString()); + const sharedPosts = await this.postsRepository.findManyByIds(sharedPostIds); + const postById = new Map(sharedPosts.map((post) => [post.id, post])); + const visibleShares: Array<{ share: ProfileShareRecord; originalPost: PostDocument }> = []; + + for (const share of profileShares) { + const originalPost = postById.get(share.postId.toString()); + if (!originalPost || !this.postMatchesProfileQuery(originalPost, query)) { + continue; + } + if (!(await this.canViewerSeePost(viewerUserId, originalPost))) { + continue; + } + visibleShares.push({ share, originalPost }); + } + + const allPostIds = [ + ...originalPosts.map((item) => item.id), + ...visibleShares.map((item) => item.originalPost.id), + ]; + const viewerSharedPostIds = viewerUserId + ? await this.findSharedPostIdSet(viewerUserId, allPostIds) + : new Set(); + const sharedByUser = await this.usersRepository.findById(userId); + + const timelineItems = [ + ...originalPosts.map((post) => ({ + sortDate: new Date((post as any).createdAt ?? 0).getTime(), + item: this.formatPostItem(post, viewerSharedPostIds), + })), + ...visibleShares.map(({ share, originalPost }) => ({ + sortDate: new Date(share.createdAt ?? 0).getTime(), + item: this.formatSharedPostItem(share, originalPost, sharedByUser, viewerSharedPostIds), + })), + ].sort((a, b) => b.sortDate - a.sortDate); + + const items = timelineItems.slice(skip, skip + limit).map((entry) => entry.item); + const total = timelineItems.length; return buildPaginatedResponse(items, { page, @@ -884,7 +960,14 @@ export class PostsService { userId: string, postId: string, dto: SharePostDto = {}, - ): Promise<{ success: true; postId: string; shareCount: number }> { + ): Promise<{ + message: string; + shareId: string; + postId: string; + shareCount: number; + isSharedByMe: boolean; + shareUrl: string; + }> { const target = dto.target; if (target === PostShareTarget.FRIEND) { if (!dto.friendId) { @@ -894,32 +977,96 @@ export class PostsService { friendId: dto.friendId, caption: dto.caption, }); - return { success: true, postId, shareCount: result.shareCount }; + return { + message: 'Post shared successfully', + shareId: '', + postId, + shareCount: result.shareCount, + isSharedByMe: false, + shareUrl: this.buildPostShareUrl(postId), + }; } - const channel = - target === PostShareTarget.COPY_LINK || dto.channel === PostShareChannel.COPY_LINK - ? PostShareChannel.COPY_LINK - : dto.channel; - const shareTarget = - target ?? - (channel === PostShareChannel.COPY_LINK - ? PostShareTarget.COPY_LINK - : channel - ? PostShareTarget.EXTERNAL - : undefined); + if ( + target === PostShareTarget.EXTERNAL || + target === PostShareTarget.COPY_LINK || + dto.channel === PostShareChannel.COPY_LINK + ) { + const post = await this.assertPostAvailableToViewer(userId, postId); + const channel = + target === PostShareTarget.COPY_LINK || dto.channel === PostShareChannel.COPY_LINK + ? PostShareChannel.COPY_LINK + : (dto.channel ?? PostShareChannel.OTHER); + await this.recordShareEvent(userId, postId, { + target: target ?? (channel === PostShareChannel.COPY_LINK ? PostShareTarget.COPY_LINK : PostShareTarget.EXTERNAL), + channel, + caption: dto.caption, + }); + const shareCount = await this.incrementShareCountAndNotify(userId, postId, post); + return { + message: 'Post shared successfully', + shareId: '', + postId, + shareCount, + isSharedByMe: false, + shareUrl: this.buildPostShareUrl(postId), + }; + } + + const channel = dto.channel; + const shareTarget = PostShareTarget.PROFILE; const post = await this.assertPostAvailableToViewer(userId, postId); - await this.recordShareEvent(userId, postId, { + const { share, created } = await this.createProfileShareRecord(userId, postId, post, { target: shareTarget, channel, caption: dto.caption, }); - const shareCount = await this.incrementShareCountAndNotify(userId, postId, post); + const shareCount = created + ? await this.incrementShareCountAndNotify(userId, postId, post) + : (post.shareCount ?? 0); return { - success: true, + message: 'Post shared successfully', + shareId: share._id.toString(), postId, shareCount, + isSharedByMe: true, + shareUrl: this.buildPostShareUrl(postId), + }; + } + + async unsharePost( + userId: string, + postId: string, + ): Promise<{ message: string; postId: string; shareCount: number; isSharedByMe: false }> { + if (!Types.ObjectId.isValid(postId)) { + throw new BadRequestException('Invalid post id'); + } + + const post = await this.postsRepository.findById(postId); + if (!post) { + throw new NotFoundException('Post not found'); + } + + const now = new Date(); + const result = await this.connection.collection('postshares').findOneAndUpdate( + this.activeProfileShareFilter(userId, postId), + { $set: { isDeleted: true, deletedAt: now, updatedAt: now } }, + { returnDocument: 'after' }, + ); + + let shareCount = post.shareCount ?? 0; + if (result) { + await this.postsRepository.incrementShareCount(postId, -1); + await this.feedVersionService.bumpGlobalVersion(); + shareCount = Math.max(0, shareCount - 1); + } + + return { + message: 'Post unshared successfully', + postId, + shareCount, + isSharedByMe: false, }; } @@ -1966,6 +2113,213 @@ export class PostsService { return (post.shareCount ?? 0) + 1; } + private activeProfileShareFilter(userId: string, postId?: string) { + return { + userId: new Types.ObjectId(userId), + ...(postId ? { postId: new Types.ObjectId(postId) } : {}), + target: PostShareTarget.PROFILE, + $or: [{ isDeleted: false }, { isDeleted: { $exists: false } }], + deletedAt: null, + }; + } + + private async createProfileShareRecord( + userId: string, + postId: string, + post: PostDocument, + payload: { target?: PostShareTarget; channel?: PostShareChannel; caption?: string }, + ): Promise<{ share: ProfileShareRecord; created: boolean }> { + const existing = await this.connection + .collection('postshares') + .findOne(this.activeProfileShareFilter(userId, postId)); + if (existing) { + return { share: existing, created: false }; + } + + const authorId = this.extractEntityId(post.authorId); + const now = new Date(); + const share = { + userId: new Types.ObjectId(userId), + postId: new Types.ObjectId(postId), + originalAuthorId: new Types.ObjectId(authorId), + sharedFrom: { + type: 'user', + id: authorId, + name: this.formatUserDisplayName(post.authorId), + }, + target: PostShareTarget.PROFILE, + channel: payload.channel ?? '', + caption: payload.caption?.trim() ?? '', + shareUrl: this.buildPostShareUrl(postId), + friendId: null, + sharedPostId: null, + conversationId: null, + messageId: null, + isDeleted: false, + deletedAt: null, + createdAt: now, + updatedAt: now, + }; + + try { + const result = await this.connection.collection('postshares').insertOne(share); + return { share: { ...share, _id: result.insertedId }, created: true }; + } catch (error: any) { + if (error?.code !== 11000) { + throw error; + } + const duplicate = await this.connection + .collection('postshares') + .findOne(this.activeProfileShareFilter(userId, postId)); + if (!duplicate) { + throw error; + } + return { share: duplicate, created: false }; + } + } + + private async countActiveProfileShares(userId: string): Promise { + return this.connection + .collection('postshares') + .countDocuments(this.activeProfileShareFilter(userId)); + } + + private async findActiveProfileShares(userId: string): Promise { + return this.connection + .collection('postshares') + .find(this.activeProfileShareFilter(userId)) + .sort({ createdAt: -1 }) + .toArray(); + } + + private async findSharedPostIdSet(userId: string, postIds: string[]): Promise> { + const validPostIds = Array.from(new Set(postIds.filter((id) => Types.ObjectId.isValid(id)))); + if (!Types.ObjectId.isValid(userId) || validPostIds.length === 0) { + return new Set(); + } + + const rows = await this.connection + .collection('postshares') + .find({ + ...this.activeProfileShareFilter(userId), + postId: { $in: validPostIds.map((id) => new Types.ObjectId(id)) }, + }) + .project({ postId: 1 }) + .toArray(); + + return new Set(rows.map((row) => row.postId.toString())); + } + + private formatPostItem(post: PostDocument | Record, sharedPostIds: Set) { + const item = + post && typeof (post as any).toObject === 'function' + ? ((post as any).toObject() as Record) + : ({ ...post } as Record); + const postId = this.extractEntityId(item._id ?? item.id); + + return { + type: 'post', + ...item, + id: postId, + shareCount: Number(item.shareCount ?? 0), + isSharedByMe: sharedPostIds.has(postId), + }; + } + + private formatSharedPostItem( + share: ProfileShareRecord, + originalPost: PostDocument, + sharedByUser: unknown, + sharedPostIds: Set, + ) { + const originalAuthor = (originalPost as any).authorId; + + return { + id: share._id.toString(), + type: 'shared_post', + sharedAt: (share.createdAt ?? new Date(0)).toISOString(), + sharedBy: this.formatUserSummary(sharedByUser), + sharedFrom: this.formatUserSummary(originalAuthor), + originalPost: this.formatPostItem(originalPost, sharedPostIds), + }; + } + + private formatUserSummary(user: unknown) { + const userObject = + user && typeof (user as any).toObject === 'function' + ? (user as any).toObject() + : ((user ?? {}) as Record); + const id = this.extractEntityId(userObject); + + return { + id, + name: userObject.name ?? '', + username: userObject.username ?? '', + avatar: this.resolveAvatarUrl(userObject.avatar), + }; + } + + private formatUserDisplayName(user: unknown): string { + const summary = this.formatUserSummary(user); + return summary.name || summary.username || summary.id; + } + + private postMatchesProfileQuery(post: PostDocument, query: PostQueryDto): boolean { + const postTypeFilter = this.resolvePostTypeFilter(query.mediaType ?? query.postType); + if (postTypeFilter && post.postType !== postTypeFilter) { + return false; + } + + if (query.q?.trim()) { + const needle = query.q.trim().toLowerCase(); + const content = this.combinePostText( + post.content ?? '', + (post as any).contentTop ?? '', + (post as any).contentBottom ?? '', + ).toLowerCase(); + if (!content.includes(needle)) { + return false; + } + } + + if (query.hashtag?.trim()) { + const hashtag = query.hashtag.trim().replace(/^#+/, '').toLowerCase(); + if (!(post.hashtags ?? []).includes(hashtag)) { + return false; + } + } + + return true; + } + + private async canViewerSeePost(viewerUserId: string | undefined, post: PostDocument): Promise { + if (post.isDeleted || post.isArchived || post.moderationStatus === ModerationStatus.HIDDEN) { + return false; + } + + const authorId = this.extractEntityId(post.authorId); + if (!authorId) { + return false; + } + const author = await this.usersRepository.findById(authorId); + if (!author || author.isDisabled) { + return false; + } + if (viewerUserId && viewerUserId === authorId) { + return true; + } + if (viewerUserId && (await this.hasBlockBetween(viewerUserId, authorId))) { + return false; + } + if (post.visibility === PostVisibility.PRIVATE) { + return false; + } + if (post.visibility === PostVisibility.FOLLOWERS) { + return !!viewerUserId && (await this.isFollowing(viewerUserId, authorId)); + } + return true; + } + private async recordShareEvent( userId: string, postId: string, diff --git a/src/modules/posts/schemas/post-share.schema.ts b/src/modules/posts/schemas/post-share.schema.ts new file mode 100644 index 0000000..4517eba --- /dev/null +++ b/src/modules/posts/schemas/post-share.schema.ts @@ -0,0 +1,72 @@ +import { Prop, Schema, SchemaFactory, raw } from '@nestjs/mongoose'; +import { HydratedDocument, Types } from 'mongoose'; +import { PostShareTarget } from '../dto/share-post.dto'; + +export type PostShareDocument = HydratedDocument; + +export const sharedFromSchema = raw({ + type: { type: String, enum: ['user', 'profile', 'feed', 'unknown'], default: 'unknown' }, + id: { type: String, default: '' }, + name: { type: String, default: '' }, +}); + +@Schema({ timestamps: true, versionKey: false, collection: 'postshares' }) +export class PostShare { + @Prop({ type: Types.ObjectId, required: true, index: true }) + userId!: Types.ObjectId; + + @Prop({ type: Types.ObjectId, required: true, index: true }) + postId!: Types.ObjectId; + + @Prop({ type: Types.ObjectId, required: true, index: true }) + originalAuthorId!: Types.ObjectId; + + @Prop({ type: sharedFromSchema, default: null }) + sharedFrom?: { type: 'user' | 'profile' | 'feed' | 'unknown'; id?: string; name?: string } | null; + + @Prop({ type: String, enum: Object.values(PostShareTarget), default: PostShareTarget.PROFILE, index: true }) + target!: PostShareTarget; + + @Prop({ default: '', trim: true }) + channel!: string; + + @Prop({ default: '', trim: true, maxlength: 2200 }) + caption!: string; + + @Prop({ default: '' }) + shareUrl!: string; + + @Prop({ type: Types.ObjectId, default: null }) + friendId?: Types.ObjectId | null; + + @Prop({ type: Types.ObjectId, default: null }) + sharedPostId?: Types.ObjectId | null; + + @Prop({ type: Types.ObjectId, default: null }) + conversationId?: Types.ObjectId | null; + + @Prop({ type: Types.ObjectId, default: null }) + messageId?: Types.ObjectId | null; + + @Prop({ default: false, index: true }) + isDeleted!: boolean; + + @Prop({ type: Date, default: null, index: true }) + deletedAt?: Date | null; +} + +export const PostShareSchema = SchemaFactory.createForClass(PostShare); + +PostShareSchema.index({ userId: 1, createdAt: -1 }); +PostShareSchema.index({ postId: 1, createdAt: -1 }); +PostShareSchema.index( + { userId: 1, postId: 1 }, + { + unique: true, + partialFilterExpression: { + target: PostShareTarget.PROFILE, + isDeleted: false, + deletedAt: null, + }, + }, +);