Add post share sheet and share targets
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-06-23 17:28:08 +03:00
الأصل e1edc1c848
التزام 7e354cd544
10 ملفات معدلة مع 1097 إضافات و32 حذوفات

عرض الملف

@@ -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');"
]
}
}
]
}
]
},

عرض الملف

@@ -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 '';

عرض الملف

@@ -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 ?? '';