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",
|
||||
"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');"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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');"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
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 { 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<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 {
|
||||
if (!value) {
|
||||
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 { 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')
|
||||
|
||||
@@ -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<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> {
|
||||
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<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 {
|
||||
const configured =
|
||||
this.configService.get<string>('PUBLIC_APP_URL') ?? process.env.PUBLIC_APP_URL ?? '';
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم