Add post share sheet and share targets
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -2168,7 +2168,16 @@
|
|||||||
"key": "Authorization",
|
"key": "Authorization",
|
||||||
"value": "Bearer {{accessToken}}"
|
"value": "Bearer {{accessToken}}"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"target\": \"copy_link\",\n \"channel\": \"copy_link\"\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"event": [
|
"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');"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1677,7 +1677,16 @@
|
|||||||
"key": "Authorization",
|
"key": "Authorization",
|
||||||
"value": "Bearer {{accessToken}}"
|
"value": "Bearer {{accessToken}}"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"target\": \"copy_link\",\n \"channel\": \"copy_link\"\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"event": [
|
"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');"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
74
src/modules/posts/dto/share-post.dto.ts
Normal file
74
src/modules/posts/dto/share-post.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -28,6 +28,12 @@ import { CreatePostDto } from './dto/create-post.dto';
|
|||||||
import { CreateRepostDto } from './dto/create-repost.dto';
|
import { CreateRepostDto } from './dto/create-repost.dto';
|
||||||
import { PostQueryDto } from './dto/post-query.dto';
|
import { PostQueryDto } from './dto/post-query.dto';
|
||||||
import { ReelQueryDto } from './dto/reel-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 { UpdateCommentSettingsDto } from './dto/update-comment-settings.dto';
|
||||||
import { UpdatePostDto } from './dto/update-post.dto';
|
import { UpdatePostDto } from './dto/update-post.dto';
|
||||||
import { PostsService } from './posts.service';
|
import { PostsService } from './posts.service';
|
||||||
@@ -328,11 +334,58 @@ export class PostsController {
|
|||||||
return this.postsService.registerPlay(user.sub, postId);
|
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()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post(':postId/share')
|
@Post(':postId/share')
|
||||||
async registerShare(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
|
async registerShare(
|
||||||
return this.postsService.registerShare(user.sub, postId);
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('postId') postId: string,
|
||||||
|
@Body() dto: SharePostDto,
|
||||||
|
) {
|
||||||
|
return this.postsService.registerShare(user.sub, postId, dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
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 { 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';
|
||||||
@@ -18,6 +21,9 @@ import { PostsService } from './posts.service';
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
BlocksModule,
|
||||||
|
ChatModule,
|
||||||
|
FollowsModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,27 +9,51 @@ const createService = () => {
|
|||||||
count: jest.fn().mockResolvedValue(0),
|
count: jest.fn().mockResolvedValue(0),
|
||||||
findById: jest.fn(),
|
findById: jest.fn(),
|
||||||
updateById: jest.fn(),
|
updateById: jest.fn(),
|
||||||
|
incrementShareCount: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const connection = {
|
const connection = {
|
||||||
collection: jest.fn(() => ({
|
collection: jest.fn(() => collectionMock),
|
||||||
countDocuments: jest.fn().mockResolvedValue(0),
|
};
|
||||||
})),
|
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(
|
const service = new PostsService(
|
||||||
connection as any,
|
connection as any,
|
||||||
postsRepository as any,
|
postsRepository as any,
|
||||||
{} as any,
|
usersRepository as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
{ bumpGlobalVersion: jest.fn().mockResolvedValue(undefined) } as any,
|
{ bumpGlobalVersion: jest.fn().mockResolvedValue(undefined) } as any,
|
||||||
|
notificationsService as any,
|
||||||
{} 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', () => {
|
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', () => {
|
describe('Post schema response aliases', () => {
|
||||||
it('returns isPinned as a stable alias for pinnedToProfile', () => {
|
it('returns isPinned as a stable alias for pinnedToProfile', () => {
|
||||||
const transform = PostSchema.get('toObject')?.transform as (
|
const transform = PostSchema.get('toObject')?.transform as (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PostType } from '../../common/enums/post-type.enum';
|
|||||||
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||||
import { ProcessingStatus } from '../../common/enums/processing-status.enum';
|
import { ProcessingStatus } from '../../common/enums/processing-status.enum';
|
||||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||||
|
import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
|
||||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||||
import {
|
import {
|
||||||
buildWaveformPeakSet,
|
buildWaveformPeakSet,
|
||||||
@@ -33,6 +34,9 @@ import {
|
|||||||
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
|
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { AuditService } from '../audit/audit.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 { UsersRepository } from '../users/users.repository';
|
||||||
import { AdminPostQueryDto } from './dto/admin-post-query.dto';
|
import { AdminPostQueryDto } from './dto/admin-post-query.dto';
|
||||||
import { CreateReelDto } from './dto/create-reel.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 { CreateRepostDto } from './dto/create-repost.dto';
|
||||||
import { PostQueryDto } from './dto/post-query.dto';
|
import { PostQueryDto } from './dto/post-query.dto';
|
||||||
import { ReelQueryDto } from './dto/reel-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 { UpdateCommentSettingsDto } from './dto/update-comment-settings.dto';
|
||||||
import { UpdatePostDto } from './dto/update-post.dto';
|
import { UpdatePostDto } from './dto/update-post.dto';
|
||||||
import { PostDocument, PostImageItem, PostMediaVariantSet } from './schemas/post.schema';
|
import { PostDocument, PostImageItem, PostMediaVariantSet } from './schemas/post.schema';
|
||||||
@@ -98,6 +110,9 @@ export class PostsService {
|
|||||||
private readonly feedVersionService: FeedVersionService,
|
private readonly feedVersionService: FeedVersionService,
|
||||||
private readonly notificationsService: NotificationsService,
|
private readonly notificationsService: NotificationsService,
|
||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
|
private readonly chatService: ChatService,
|
||||||
|
private readonly followsRepository: FollowsRepository,
|
||||||
|
private readonly blocksRepository: BlocksRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(
|
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(
|
async registerShare(
|
||||||
userId: string,
|
userId: string,
|
||||||
postId: string,
|
postId: string,
|
||||||
|
dto: SharePostDto = {},
|
||||||
): Promise<{ success: true; postId: string; shareCount: number }> {
|
): Promise<{ success: true; postId: string; shareCount: number }> {
|
||||||
const post = await this.postsRepository.findById(postId);
|
const target = dto.target;
|
||||||
if (!post) {
|
if (target === PostShareTarget.FRIEND) {
|
||||||
throw new NotFoundException('Post not found');
|
if (!dto.friendId) {
|
||||||
}
|
throw new BadRequestException('friendId is required when target is friend');
|
||||||
|
|
||||||
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 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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
postId,
|
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(
|
async updateCommentSettings(
|
||||||
userId: string,
|
userId: string,
|
||||||
postId: string,
|
postId: string,
|
||||||
@@ -1801,6 +1905,217 @@ export class PostsService {
|
|||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertPostAvailableToViewer(userId: string, postId: string): Promise<PostDocument> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<T extends { id: string }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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 {
|
private extractEntityId(value: unknown): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
10
src/modules/users/dto/share-friends-query.dto.ts
Normal file
10
src/modules/users/dto/share-friends-query.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { AdminDisableUserDto } from './dto/admin-disable-user.dto';
|
|||||||
import { CreateAdminDto } from './dto/create-admin.dto';
|
import { CreateAdminDto } from './dto/create-admin.dto';
|
||||||
import { MusicSetupDto } from './dto/music-setup.dto';
|
import { MusicSetupDto } from './dto/music-setup.dto';
|
||||||
import { ProfileSetupDto } from './dto/profile-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 { TalentDiscoverQueryDto } from './dto/talent-discover-query.dto';
|
||||||
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
|
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
@@ -288,6 +289,13 @@ export class UsersController {
|
|||||||
return this.usersService.getMyDashboard(user.sub);
|
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()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('by-username/:username')
|
@Get('by-username/:username')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
import { Connection, Types } from 'mongoose';
|
import { Connection, Types } from 'mongoose';
|
||||||
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
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 { ExperienceLevel } from '../../common/enums/experience-level.enum';
|
||||||
import { MusicRole } from '../../common/enums/music-role.enum';
|
import { MusicRole } from '../../common/enums/music-role.enum';
|
||||||
import { hashValue } from '../../common/utils/hash.util';
|
import { hashValue } from '../../common/utils/hash.util';
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
import { CreateAdminDto } from './dto/create-admin.dto';
|
import { CreateAdminDto } from './dto/create-admin.dto';
|
||||||
import { MusicSetupDto } from './dto/music-setup.dto';
|
import { MusicSetupDto } from './dto/music-setup.dto';
|
||||||
import { ProfileSetupDto } from './dto/profile-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 { TalentDiscoverQueryDto } from './dto/talent-discover-query.dto';
|
||||||
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
|
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
|
||||||
import { UpdateUserDto } from './dto/update-user.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<string, any>[]; 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<UserPostStats> {
|
private async aggregateUserPostStats(authorId: Types.ObjectId): Promise<UserPostStats> {
|
||||||
const [row] = await this.connection
|
const [row] = await this.connection
|
||||||
.collection('posts')
|
.collection('posts')
|
||||||
@@ -1058,6 +1124,87 @@ export class UsersService {
|
|||||||
return `${appUrl}/profile/${this.extractUserId(user)}`;
|
return `${appUrl}/profile/${this.extractUserId(user)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertPostShareableForViewer(viewerUserId: string, postId: string): Promise<void> {
|
||||||
|
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<Types.ObjectId[]> {
|
||||||
|
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<string, any>) {
|
||||||
|
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 {
|
private resolvePublicAppUrl(): string {
|
||||||
const configured =
|
const configured =
|
||||||
this.configService.get<string>('PUBLIC_APP_URL') ?? process.env.PUBLIC_APP_URL ?? '';
|
this.configService.get<string>('PUBLIC_APP_URL') ?? process.env.PUBLIC_APP_URL ?? '';
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم