Add shared posts to user profiles
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-07-05 01:07:41 +03:00
الأصل 4b9589a3fc
التزام 7cd239f8e0
6 ملفات معدلة مع 808 إضافات و28 حذوفات

عرض الملف

@@ -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,

عرض الملف

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