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