diff --git a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json index 4680a0b..f82f9e2 100644 --- a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json +++ b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json @@ -2168,7 +2168,16 @@ "key": "Authorization", "value": "Bearer {{accessToken}}" } - ] + ], + "body": { + "mode": "raw", + "raw": "{\n \"target\": \"copy_link\",\n \"channel\": \"copy_link\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } }, "event": [ { @@ -3522,6 +3531,172 @@ } } ] + }, + { + "name": "Get Post Share Sheet", + "request": { + "method": "GET", + "url": "{{baseUrl}}/posts/{{postId}}/share-sheet", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property('postId');", + "pm.expect(json).to.have.property('shareUrl');", + "pm.expect(json).to.have.property('suggestedFriends');" + ] + } + } + ] + }, + { + "name": "Share Post To Profile", + "request": { + "method": "POST", + "url": "{{baseUrl}}/posts/{{postId}}/share/profile", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"caption\": \"شوفوا هذا العمل\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.shared).to.eql(true);", + "pm.expect(json).to.have.property('shareCount');", + "if (json.post) pm.environment.set('sharedPostId', json.post._id || json.post.id);" + ] + } + } + ] + }, + { + "name": "Share Post To Friend", + "request": { + "method": "POST", + "url": "{{baseUrl}}/posts/{{postId}}/share/friend", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"friendId\": \"{{targetUserId}}\",\n \"caption\": \"شوف هذا البوست\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.sent).to.eql(true);", + "pm.expect(json).to.have.property('conversationId');", + "pm.expect(json).to.have.property('messageId');", + "pm.environment.set('conversationId', json.conversationId);", + "pm.environment.set('lastMessageId', json.messageId);" + ] + } + } + ] + }, + { + "name": "Get Share Friends", + "request": { + "method": "GET", + "url": "{{baseUrl}}/users/me/share-friends?postId={{postId}}&page=1&limit=20", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property('items');", + "pm.expect(json.items).to.be.an('array');" + ] + } + } + ] + }, + { + "name": "Share Post External", + "request": { + "method": "POST", + "url": "{{baseUrl}}/posts/{{postId}}/share/external", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"channel\": \"whatsapp\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property('shareCount');" + ] + } + } + ] } ] }, diff --git a/postman/Oudelaa-Mobile.postman_collection.json b/postman/Oudelaa-Mobile.postman_collection.json index 0b73011..24c5625 100644 --- a/postman/Oudelaa-Mobile.postman_collection.json +++ b/postman/Oudelaa-Mobile.postman_collection.json @@ -1677,7 +1677,16 @@ "key": "Authorization", "value": "Bearer {{accessToken}}" } - ] + ], + "body": { + "mode": "raw", + "raw": "{\n \"target\": \"copy_link\",\n \"channel\": \"copy_link\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } }, "event": [ { @@ -2994,6 +3003,172 @@ } } ] + }, + { + "name": "Get Post Share Sheet", + "request": { + "method": "GET", + "url": "{{baseUrl}}/posts/{{postId}}/share-sheet", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property('postId');", + "pm.expect(json).to.have.property('shareUrl');", + "pm.expect(json).to.have.property('suggestedFriends');" + ] + } + } + ] + }, + { + "name": "Share Post To Profile", + "request": { + "method": "POST", + "url": "{{baseUrl}}/posts/{{postId}}/share/profile", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"caption\": \"شوفوا هذا العمل\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.shared).to.eql(true);", + "pm.expect(json).to.have.property('shareCount');", + "if (json.post) pm.environment.set('sharedPostId', json.post._id || json.post.id);" + ] + } + } + ] + }, + { + "name": "Share Post To Friend", + "request": { + "method": "POST", + "url": "{{baseUrl}}/posts/{{postId}}/share/friend", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"friendId\": \"{{targetUserId}}\",\n \"caption\": \"شوف هذا البوست\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.sent).to.eql(true);", + "pm.expect(json).to.have.property('conversationId');", + "pm.expect(json).to.have.property('messageId');", + "pm.environment.set('conversationId', json.conversationId);", + "pm.environment.set('lastMessageId', json.messageId);" + ] + } + } + ] + }, + { + "name": "Get Share Friends", + "request": { + "method": "GET", + "url": "{{baseUrl}}/users/me/share-friends?postId={{postId}}&page=1&limit=20", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property('items');", + "pm.expect(json.items).to.be.an('array');" + ] + } + } + ] + }, + { + "name": "Share Post External", + "request": { + "method": "POST", + "url": "{{baseUrl}}/posts/{{postId}}/share/external", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"channel\": \"whatsapp\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.have.property('shareCount');" + ] + } + } + ] } ] }, diff --git a/src/modules/posts/dto/share-post.dto.ts b/src/modules/posts/dto/share-post.dto.ts new file mode 100644 index 0000000..cb5668d --- /dev/null +++ b/src/modules/posts/dto/share-post.dto.ts @@ -0,0 +1,74 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEnum, + IsMongoId, + IsOptional, + IsString, + Length, + ValidateIf, +} from 'class-validator'; + +export enum PostShareTarget { + PROFILE = 'profile', + FRIEND = 'friend', + EXTERNAL = 'external', + COPY_LINK = 'copy_link', +} + +export enum PostShareChannel { + WHATSAPP = 'whatsapp', + INSTAGRAM = 'instagram', + MESSENGER = 'messenger', + COPY_LINK = 'copy_link', + SYSTEM_SHARE = 'system_share', + OTHER = 'other', +} + +export class SharePostDto { + @ApiPropertyOptional({ enum: PostShareTarget }) + @IsOptional() + @IsEnum(PostShareTarget) + target?: PostShareTarget; + + @ApiPropertyOptional({ maxLength: 2200 }) + @IsOptional() + @IsString() + @Length(0, 2200) + caption?: string; + + @ApiPropertyOptional() + @ValidateIf((dto: SharePostDto) => dto.target === PostShareTarget.FRIEND || !!dto.friendId) + @IsMongoId() + friendId?: string; + + @ApiPropertyOptional({ enum: PostShareChannel }) + @IsOptional() + @IsEnum(PostShareChannel) + channel?: PostShareChannel; +} + +export class SharePostToProfileDto { + @ApiPropertyOptional({ maxLength: 2200 }) + @IsOptional() + @IsString() + @Length(0, 2200) + caption?: string; +} + +export class SharePostToFriendDto { + @IsMongoId() + friendId!: string; + + @ApiPropertyOptional({ maxLength: 2200 }) + @IsOptional() + @IsString() + @Length(0, 2200) + caption?: string; +} + +export class SharePostExternalDto { + @ApiPropertyOptional({ enum: PostShareChannel }) + @IsOptional() + @IsEnum(PostShareChannel) + channel?: PostShareChannel; +} diff --git a/src/modules/posts/posts.controller.ts b/src/modules/posts/posts.controller.ts index e849fa0..6df14dc 100644 --- a/src/modules/posts/posts.controller.ts +++ b/src/modules/posts/posts.controller.ts @@ -28,6 +28,12 @@ import { CreatePostDto } from './dto/create-post.dto'; import { CreateRepostDto } from './dto/create-repost.dto'; import { PostQueryDto } from './dto/post-query.dto'; import { ReelQueryDto } from './dto/reel-query.dto'; +import { + SharePostDto, + SharePostExternalDto, + SharePostToFriendDto, + SharePostToProfileDto, +} from './dto/share-post.dto'; import { UpdateCommentSettingsDto } from './dto/update-comment-settings.dto'; import { UpdatePostDto } from './dto/update-post.dto'; import { PostsService } from './posts.service'; @@ -328,11 +334,58 @@ export class PostsController { return this.postsService.registerPlay(user.sub, postId); } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get(':postId/share-sheet') + async getShareSheet(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { + return this.postsService.getShareSheet(user.sub, postId); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post(':postId/share/profile') + async shareToProfile( + @CurrentUser() user: JwtPayload, + @Param('postId') postId: string, + @Body() dto: SharePostToProfileDto, + ) { + return this.postsService.sharePostToProfile(user.sub, postId, dto); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post(':postId/share/friend') + async shareToFriend( + @CurrentUser() user: JwtPayload, + @Param('postId') postId: string, + @Body() dto: SharePostToFriendDto, + ) { + return this.postsService.sharePostToFriend(user.sub, postId, dto); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post(':postId/share/external') + async shareExternal( + @CurrentUser() user: JwtPayload, + @Param('postId') postId: string, + @Body() dto: SharePostExternalDto, + ) { + return this.postsService.sharePostExternal(user.sub, postId, dto); + } + @ApiBearerAuth() @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post(':postId/share') - async registerShare(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) { - return this.postsService.registerShare(user.sub, postId); + async registerShare( + @CurrentUser() user: JwtPayload, + @Param('postId') postId: string, + @Body() dto: SharePostDto, + ) { + return this.postsService.registerShare(user.sub, postId, dto); } } diff --git a/src/modules/posts/posts.module.ts b/src/modules/posts/posts.module.ts index 834de91..2da9982 100644 --- a/src/modules/posts/posts.module.ts +++ b/src/modules/posts/posts.module.ts @@ -1,6 +1,9 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { AuditModule } from '../audit/audit.module'; +import { BlocksModule } from '../blocks/blocks.module'; +import { ChatModule } from '../chat/chat.module'; +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'; @@ -18,6 +21,9 @@ import { PostsService } from './posts.service'; }, ]), AuditModule, + BlocksModule, + ChatModule, + FollowsModule, NotificationsModule, UsersModule, ], diff --git a/src/modules/posts/posts.service.spec.ts b/src/modules/posts/posts.service.spec.ts index b839ed7..a8f2cca 100644 --- a/src/modules/posts/posts.service.spec.ts +++ b/src/modules/posts/posts.service.spec.ts @@ -9,27 +9,51 @@ const createService = () => { count: jest.fn().mockResolvedValue(0), findById: jest.fn(), updateById: jest.fn(), + incrementShareCount: jest.fn().mockResolvedValue(undefined), }; const connection = { - collection: jest.fn(() => ({ - countDocuments: jest.fn().mockResolvedValue(0), - })), + collection: jest.fn(() => collectionMock), + }; + const collectionMock = { + countDocuments: jest.fn().mockResolvedValue(0), + insertOne: jest.fn().mockResolvedValue({ insertedId: new Types.ObjectId() }), + }; + const usersRepository = { + findById: jest.fn().mockResolvedValue({ + id: new Types.ObjectId().toString(), + isDisabled: false, + toObject: () => ({ _id: new Types.ObjectId(), username: 'viewer', name: 'Viewer' }), + }), + }; + const notificationsService = { + createShareNotification: jest.fn().mockResolvedValue(undefined), }; const service = new PostsService( connection as any, postsRepository as any, - {} as any, + usersRepository as any, {} as any, {} as any, {} as any, {} as any, { bumpGlobalVersion: jest.fn().mockResolvedValue(undefined) } as any, + notificationsService as any, {} as any, - {} as any, + { + createConversation: jest.fn(), + sendMessage: jest.fn(), + } as any, + { + findMany: jest.fn().mockResolvedValue([]), + } as any, + { + findAnyBetween: jest.fn().mockResolvedValue(null), + findBlockingOrBlockedIds: jest.fn().mockResolvedValue([]), + } as any, ); - return { service, postsRepository, connection }; + return { service, postsRepository, connection, collectionMock, usersRepository, notificationsService }; }; describe('PostsService archived profile posts', () => { @@ -103,6 +127,84 @@ describe('PostsService archived profile posts', () => { }); }); +describe('PostsService post sharing', () => { + const buildPost = (authorId = new Types.ObjectId().toString()) => ({ + id: new Types.ObjectId().toString(), + authorId: new Types.ObjectId(authorId), + content: 'Shared oud session', + visibility: PostVisibility.PUBLIC, + shareCount: 4, + isArchived: false, + moderationStatus: 'active', + }); + + it('returns a share sheet with a public post URL and suggestedFriends array', async () => { + const viewerId = new Types.ObjectId().toString(); + const authorId = new Types.ObjectId().toString(); + const post = buildPost(authorId); + const { service, postsRepository, usersRepository } = createService(); + postsRepository.findById.mockResolvedValue(post); + usersRepository.findById.mockImplementation((id: string) => + Promise.resolve({ + id, + isDisabled: false, + toObject: () => ({ + _id: new Types.ObjectId(id), + username: id === viewerId ? 'viewer' : 'author', + name: id === viewerId ? 'Viewer' : 'Author', + stageName: '', + avatar: '', + }), + }), + ); + + const result = await service.getShareSheet(viewerId, post.id); + + expect(result).toEqual( + expect.objectContaining({ + postId: post.id, + shareUrl: `https://oudelaa.com/posts/${post.id}`, + shareCount: 4, + suggestedFriends: [], + }), + ); + expect(result.viewer).toEqual(expect.objectContaining({ id: viewerId, username: 'viewer' })); + }); + + it('keeps legacy POST /share behavior without a body', async () => { + const viewerId = new Types.ObjectId().toString(); + const post = buildPost(); + const { service, postsRepository } = 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 }); + }); + + it('records copy_link shares and increments shareCount', async () => { + const viewerId = new Types.ObjectId().toString(); + const post = buildPost(); + const { service, postsRepository, collectionMock } = createService(); + postsRepository.findById.mockResolvedValue(post); + + const result = await service.registerShare(viewerId, post.id, { + target: 'copy_link' as any, + channel: 'copy_link' as any, + }); + + expect(collectionMock.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ + target: 'copy_link', + channel: 'copy_link', + shareUrl: `https://oudelaa.com/posts/${post.id}`, + }), + ); + expect(result.shareCount).toBe(5); + }); +}); + describe('Post schema response aliases', () => { it('returns isPinned as a stable alias for pinnedToProfile', () => { const transform = PostSchema.get('toObject')?.transform as ( diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index b6e0d0a..9f8b5b3 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -13,6 +13,7 @@ import { PostType } from '../../common/enums/post-type.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { ProcessingStatus } from '../../common/enums/processing-status.enum'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; +import { resolveManagedFileUrl } from '../../common/utils/public-url.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { buildWaveformPeakSet, @@ -33,6 +34,9 @@ import { import { MediaProbeService } from '../../infrastructure/storage/media-probe.service'; import { NotificationsService } from '../notifications/notifications.service'; import { AuditService } from '../audit/audit.service'; +import { BlocksRepository } from '../blocks/blocks.repository'; +import { ChatService } from '../chat/chat.service'; +import { FollowsRepository } from '../follows/follows.repository'; import { UsersRepository } from '../users/users.repository'; import { AdminPostQueryDto } from './dto/admin-post-query.dto'; import { CreateReelDto } from './dto/create-reel.dto'; @@ -40,6 +44,14 @@ import { CreatePostDto } from './dto/create-post.dto'; import { CreateRepostDto } from './dto/create-repost.dto'; import { PostQueryDto } from './dto/post-query.dto'; import { ReelQueryDto } from './dto/reel-query.dto'; +import { + PostShareChannel, + PostShareTarget, + SharePostDto, + SharePostExternalDto, + SharePostToFriendDto, + SharePostToProfileDto, +} from './dto/share-post.dto'; import { UpdateCommentSettingsDto } from './dto/update-comment-settings.dto'; import { UpdatePostDto } from './dto/update-post.dto'; import { PostDocument, PostImageItem, PostMediaVariantSet } from './schemas/post.schema'; @@ -98,6 +110,9 @@ export class PostsService { private readonly feedVersionService: FeedVersionService, private readonly notificationsService: NotificationsService, private readonly auditService: AuditService, + private readonly chatService: ChatService, + private readonly followsRepository: FollowsRepository, + private readonly blocksRepository: BlocksRepository, ) {} async create( @@ -823,41 +838,130 @@ export class PostsService { }; } + async getShareSheet(userId: string, postId: string) { + const post = await this.assertPostAvailableToViewer(userId, postId); + const viewer = await this.usersRepository.findById(userId); + if (!viewer || viewer.isDisabled) { + throw new NotFoundException('User not found'); + } + + const suggestedFriends = await this.findSuggestedShareFriends(userId, 10); + + return { + postId, + shareUrl: this.buildPostShareUrl(postId), + shareCount: post.shareCount ?? 0, + viewer: this.formatShareViewer(viewer), + suggestedFriends, + }; + } + async registerShare( userId: string, postId: string, + dto: SharePostDto = {}, ): Promise<{ success: true; postId: string; shareCount: number }> { - const post = await this.postsRepository.findById(postId); - if (!post) { - throw new NotFoundException('Post not found'); - } - - await this.postsRepository.incrementShareCount(postId, 1); - await this.feedVersionService.bumpGlobalVersion(); - - const authorId = this.extractEntityId(post.authorId); - if (authorId && authorId !== userId) { - try { - await this.notificationsService.createShareNotification(userId, authorId, postId, { - resourceType: 'post', - previewText: (post.content ?? '').slice(0, 140), - }); - } catch (error) { - this.logger.warn( - `Share notification failed for actor=${userId} recipient=${authorId}: ${ - error instanceof Error ? error.message : 'unknown error' - }`, - ); + const target = dto.target; + if (target === PostShareTarget.FRIEND) { + if (!dto.friendId) { + throw new BadRequestException('friendId is required when target is friend'); } + const result = await this.sharePostToFriend(userId, postId, { + friendId: dto.friendId, + caption: dto.caption, + }); + return { success: true, postId, shareCount: result.shareCount }; } + 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); + const post = await this.assertPostAvailableToViewer(userId, postId); + await this.recordShareEvent(userId, postId, { + target: shareTarget, + channel, + caption: dto.caption, + }); + const shareCount = await this.incrementShareCountAndNotify(userId, postId, post); + return { success: true, postId, - shareCount: (post.shareCount ?? 0) + 1, + shareCount, }; } + async sharePostToProfile(userId: string, postId: string, dto: SharePostToProfileDto) { + await this.assertPostAvailableToViewer(userId, postId); + const sharedPost = await this.createRepost(userId, postId, { + content: dto.caption?.trim() ?? '', + visibility: PostVisibility.PUBLIC, + }); + const sourcePost = await this.postsRepository.findById(postId); + await this.recordShareEvent(userId, postId, { + target: PostShareTarget.PROFILE, + caption: dto.caption, + sharedPostId: sharedPost.id, + }); + + return { + shared: true, + shareCount: sourcePost?.shareCount ?? 0, + post: sharedPost, + }; + } + + async sharePostToFriend(userId: string, postId: string, dto: SharePostToFriendDto) { + const post = await this.assertPostAvailableToViewer(userId, postId); + const friend = await this.assertShareFriend(userId, dto.friendId); + const conversation = await this.chatService.createConversation(userId, { + participantIds: [dto.friendId], + isGroup: false, + }); + const shareUrl = this.buildPostShareUrl(postId); + const caption = dto.caption?.trim(); + const content = [caption, shareUrl, `shared_post:${postId}`].filter(Boolean).join('\n\n'); + const message = await this.chatService.sendMessage(userId, { + conversationId: conversation.id, + content, + messageType: 'text', + }); + + await this.recordShareEvent(userId, postId, { + target: PostShareTarget.FRIEND, + friendId: friend.id, + caption: dto.caption, + conversationId: conversation.id, + messageId: message.id, + }); + const shareCount = await this.incrementShareCountAndNotify(userId, postId, post); + + return { + sent: true, + shareCount, + conversationId: conversation.id, + messageId: message.id, + }; + } + + async sharePostExternal(userId: string, postId: string, dto: SharePostExternalDto) { + return this.registerShare(userId, postId, { + target: + dto.channel === PostShareChannel.COPY_LINK + ? PostShareTarget.COPY_LINK + : PostShareTarget.EXTERNAL, + channel: dto.channel ?? PostShareChannel.OTHER, + }); + } + async updateCommentSettings( userId: string, postId: string, @@ -1801,6 +1905,217 @@ export class PostsService { return post; } + private async assertPostAvailableToViewer(userId: string, postId: string): Promise { + if (!Types.ObjectId.isValid(postId)) { + throw new BadRequestException('Invalid post id'); + } + + const post = await this.postsRepository.findById(postId); + if (!post || post.isArchived || post.moderationStatus === ModerationStatus.HIDDEN) { + throw new NotFoundException('Post not found'); + } + + const authorId = this.extractEntityId(post.authorId); + if (!authorId) { + throw new NotFoundException('Post not found'); + } + + const author = await this.usersRepository.findById(authorId); + if (!author || author.isDisabled) { + throw new NotFoundException('Post not found'); + } + + if (authorId === userId) { + return post; + } + + if (await this.hasBlockBetween(userId, authorId)) { + throw new ForbiddenException('You cannot access this post'); + } + + if (post.visibility === PostVisibility.PRIVATE) { + throw new ForbiddenException('You cannot access this post'); + } + + if (post.visibility === PostVisibility.FOLLOWERS) { + const following = await this.isFollowing(userId, authorId); + if (!following) { + throw new ForbiddenException('You cannot access this post'); + } + } + + return post; + } + + private async assertShareFriend(userId: string, friendId: string) { + if (!Types.ObjectId.isValid(friendId)) { + throw new BadRequestException('Invalid friend id'); + } + if (friendId === userId) { + throw new BadRequestException('friendId cannot be the current user'); + } + + const friend = await this.usersRepository.findById(friendId); + if (!friend || friend.isDisabled) { + throw new NotFoundException('Friend not found'); + } + + const [globalBlock, chatBlock] = await Promise.all([ + this.blocksRepository.findAnyBetween(userId, friendId), + this.connection.collection('chatblocks').countDocuments({ + $or: [ + { blockerId: new Types.ObjectId(userId), blockedId: new Types.ObjectId(friendId) }, + { blockerId: new Types.ObjectId(friendId), blockedId: new Types.ObjectId(userId) }, + ], + }), + ]); + if (globalBlock || chatBlock > 0) { + throw new ForbiddenException('You cannot share with this user'); + } + + return friend; + } + + private async incrementShareCountAndNotify( + userId: string, + postId: string, + post: PostDocument, + ): Promise { + await this.postsRepository.incrementShareCount(postId, 1); + await this.feedVersionService.bumpGlobalVersion(); + + const authorId = this.extractEntityId(post.authorId); + if (authorId && authorId !== userId) { + try { + await this.notificationsService.createShareNotification(userId, authorId, postId, { + resourceType: 'post', + previewText: (post.content ?? '').slice(0, 140), + }); + } catch (error) { + this.logger.warn( + `Share notification failed for actor=${userId} recipient=${authorId}: ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + } + } + + return (post.shareCount ?? 0) + 1; + } + + private async recordShareEvent( + userId: string, + postId: string, + payload: { + target?: PostShareTarget; + channel?: PostShareChannel; + caption?: string; + friendId?: string; + sharedPostId?: string; + conversationId?: string; + messageId?: string; + }, + ): Promise { + await this.connection.collection('postshares').insertOne({ + postId: new Types.ObjectId(postId), + userId: new Types.ObjectId(userId), + target: payload.target ?? 'legacy', + channel: payload.channel ?? '', + caption: payload.caption?.trim() ?? '', + friendId: + payload.friendId && Types.ObjectId.isValid(payload.friendId) + ? new Types.ObjectId(payload.friendId) + : null, + sharedPostId: + payload.sharedPostId && Types.ObjectId.isValid(payload.sharedPostId) + ? new Types.ObjectId(payload.sharedPostId) + : null, + conversationId: + payload.conversationId && Types.ObjectId.isValid(payload.conversationId) + ? new Types.ObjectId(payload.conversationId) + : null, + messageId: + payload.messageId && Types.ObjectId.isValid(payload.messageId) + ? new Types.ObjectId(payload.messageId) + : null, + shareUrl: this.buildPostShareUrl(postId), + createdAt: new Date(), + }); + } + + private async findSuggestedShareFriends(userId: string, limit: number) { + const blockedIds = new Set(await this.blocksRepository.findBlockingOrBlockedIds(userId)); + const rows = await this.followsRepository.findMany( + { followerId: new Types.ObjectId(userId) }, + 0, + Math.max(limit * 2, limit), + { createdAt: -1 }, + ); + + const items = rows + .map((row: any) => row.followingId) + .filter((candidate: any) => candidate && typeof candidate === 'object') + .filter((candidate: any) => { + const id = this.extractEntityId(candidate); + return id && id !== userId && !blockedIds.has(id) && !candidate.isDisabled; + }) + .map((candidate: any) => this.formatShareFriend(candidate, true)); + + return this.uniqueById(items).slice(0, limit); + } + + private formatShareViewer(user: any) { + const userObject = typeof user.toObject === 'function' ? user.toObject() : user; + const id = this.extractEntityId(userObject); + return { + id, + username: userObject.username ?? '', + name: userObject.name ?? '', + stageName: userObject.stageName ?? '', + avatar: this.resolveAvatarUrl(userObject.avatar), + }; + } + + private formatShareFriend(user: any, isFollowing: boolean) { + const userObject = typeof user.toObject === 'function' ? user.toObject() : user; + const id = this.extractEntityId(userObject); + const stageName = typeof userObject.stageName === 'string' ? userObject.stageName.trim() : ''; + const name = typeof userObject.name === 'string' ? userObject.name.trim() : ''; + const username = typeof userObject.username === 'string' ? userObject.username.trim() : ''; + return { + id, + displayName: stageName || name || username, + username, + avatarUrl: this.resolveAvatarUrl(userObject.avatar), + isVerified: !!userObject.isVerified, + isFollowing, + }; + } + + private uniqueById(items: T[]): T[] { + const seen = new Set(); + return items.filter((item) => { + if (!item.id || seen.has(item.id)) { + return false; + } + seen.add(item.id); + return true; + }); + } + + private buildPostShareUrl(postId: string): string { + return `${this.resolvePublicAppUrl()}/posts/${encodeURIComponent(postId)}`; + } + + private resolvePublicAppUrl(): string { + return (process.env.PUBLIC_APP_URL?.trim() || 'https://oudelaa.com').replace(/\/+$/, ''); + } + + private resolveAvatarUrl(value: unknown): string { + const resolved = resolveManagedFileUrl(typeof value === 'string' ? value : ''); + return typeof resolved === 'string' ? resolved : ''; + } + private extractEntityId(value: unknown): string { if (!value) { return ''; diff --git a/src/modules/users/dto/share-friends-query.dto.ts b/src/modules/users/dto/share-friends-query.dto.ts new file mode 100644 index 0000000..34d5d2b --- /dev/null +++ b/src/modules/users/dto/share-friends-query.dto.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsMongoId, IsOptional } from 'class-validator'; +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +export class ShareFriendsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional() + @IsOptional() + @IsMongoId() + postId?: string; +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 98b9a63..314777c 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -23,6 +23,7 @@ import { AdminDisableUserDto } from './dto/admin-disable-user.dto'; import { CreateAdminDto } from './dto/create-admin.dto'; import { MusicSetupDto } from './dto/music-setup.dto'; import { ProfileSetupDto } from './dto/profile-setup.dto'; +import { ShareFriendsQueryDto } from './dto/share-friends-query.dto'; import { TalentDiscoverQueryDto } from './dto/talent-discover-query.dto'; import { UpdateUserRoleDto } from './dto/update-user-role.dto'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -288,6 +289,13 @@ export class UsersController { return this.usersService.getMyDashboard(user.sub); } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('me/share-friends') + async getShareFriends(@CurrentUser() user: JwtPayload, @Query() query: ShareFriendsQueryDto) { + return this.usersService.getShareFriends(user.sub, query); + } + @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Get('by-username/:username') diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index fba2fee..36dda97 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -9,6 +9,7 @@ import { ConfigService } from '@nestjs/config'; import { extname } from 'path'; import { Connection, Types } from 'mongoose'; import { ModerationStatus } from '../../common/enums/moderation-status.enum'; +import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { ExperienceLevel } from '../../common/enums/experience-level.enum'; import { MusicRole } from '../../common/enums/music-role.enum'; import { hashValue } from '../../common/utils/hash.util'; @@ -28,6 +29,7 @@ import { import { CreateAdminDto } from './dto/create-admin.dto'; import { MusicSetupDto } from './dto/music-setup.dto'; import { ProfileSetupDto } from './dto/profile-setup.dto'; +import { ShareFriendsQueryDto } from './dto/share-friends-query.dto'; import { TalentDiscoverQueryDto } from './dto/talent-discover-query.dto'; import { UpdateUserRoleDto } from './dto/update-user-role.dto'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -1000,6 +1002,70 @@ export class UsersService { }); } + async getShareFriends(currentUserId: string, query: ShareFriendsQueryDto) { + if (query.postId) { + await this.assertPostShareableForViewer(currentUserId, query.postId); + } + + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + const currentUserObjectId = new Types.ObjectId(currentUserId); + const blockedIds = await this.findBlockingOrBlockedIds(currentUserId); + + const [result] = await this.connection + .collection('follows') + .aggregate<{ items: Record[]; total: Array<{ count: number }> }>([ + { $match: { followerId: currentUserObjectId } }, + { + $lookup: { + from: 'users', + localField: 'followingId', + foreignField: '_id', + as: 'user', + }, + }, + { $unwind: '$user' }, + { + $match: { + 'user._id': { $ne: currentUserObjectId, ...(blockedIds.length ? { $nin: blockedIds } : {}) }, + 'user.isDisabled': { $ne: true }, + }, + }, + { $sort: { createdAt: -1 } }, + { + $facet: { + items: [ + { $skip: skip }, + { $limit: limit }, + { + $project: { + _id: '$user._id', + name: '$user.name', + username: '$user.username', + stageName: '$user.stageName', + avatar: '$user.avatar', + isVerified: '$user.isVerified', + }, + }, + ], + total: [{ $count: 'count' }], + }, + }, + ]) + .toArray(); + + const total = result?.total?.[0]?.count ?? 0; + const items = (result?.items ?? []).map((user) => this.formatShareFriend(user)); + + return buildPaginatedResponse(items, { + page, + limit, + total, + offset: skip, + }); + } + private async aggregateUserPostStats(authorId: Types.ObjectId): Promise { const [row] = await this.connection .collection('posts') @@ -1058,6 +1124,87 @@ export class UsersService { return `${appUrl}/profile/${this.extractUserId(user)}`; } + private async assertPostShareableForViewer(viewerUserId: string, postId: string): Promise { + if (!Types.ObjectId.isValid(postId)) { + throw new BadRequestException('Invalid post id'); + } + + const post = await this.connection.collection('posts').findOne({ + _id: new Types.ObjectId(postId), + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }); + if (!post) { + throw new NotFoundException('Post not found'); + } + + const authorId = post.authorId?.toString?.() ?? ''; + if (!authorId) { + throw new NotFoundException('Post not found'); + } + + const author = await this.usersRepository.findById(authorId); + if (!author || author.isDisabled) { + throw new NotFoundException('Post not found'); + } + + if (authorId === viewerUserId) { + return; + } + + const blocked = await this.connection.collection('blocks').countDocuments({ + $or: [ + { blockerId: new Types.ObjectId(viewerUserId), blockedId: new Types.ObjectId(authorId) }, + { blockerId: new Types.ObjectId(authorId), blockedId: new Types.ObjectId(viewerUserId) }, + ], + }); + if (blocked > 0) { + throw new ForbiddenException('You cannot access this post'); + } + + if (post.visibility === PostVisibility.PRIVATE) { + throw new ForbiddenException('You cannot access this post'); + } + + if (post.visibility === PostVisibility.FOLLOWERS) { + const follows = await this.connection.collection('follows').countDocuments({ + followerId: new Types.ObjectId(viewerUserId), + followingId: new Types.ObjectId(authorId), + }); + if (follows === 0) { + throw new ForbiddenException('You cannot access this post'); + } + } + } + + private async findBlockingOrBlockedIds(userId: string): Promise { + const userObjectId = new Types.ObjectId(userId); + const rows = await this.connection + .collection('blocks') + .find({ $or: [{ blockerId: userObjectId }, { blockedId: userObjectId }] }) + .project({ blockerId: 1, blockedId: 1 }) + .toArray(); + + return rows.map((row) => + row.blockerId?.toString?.() === userId ? row.blockedId : row.blockerId, + ).filter((id): id is Types.ObjectId => id instanceof Types.ObjectId); + } + + private formatShareFriend(user: Record) { + const stageName = typeof user.stageName === 'string' ? user.stageName.trim() : ''; + const name = typeof user.name === 'string' ? user.name.trim() : ''; + const username = typeof user.username === 'string' ? user.username.trim() : ''; + return { + id: user._id?.toString?.() ?? '', + displayName: stageName || name || username, + username, + avatarUrl: this.resolveManagedStringUrl(user.avatar), + isVerified: !!user.isVerified, + isFollowing: true, + }; + } + private resolvePublicAppUrl(): string { const configured = this.configService.get('PUBLIC_APP_URL') ?? process.env.PUBLIC_APP_URL ?? '';