diff --git a/platforms/pictique/api/src/controllers/UserController.ts b/platforms/pictique/api/src/controllers/UserController.ts index 0f519b874..8f6bb6851 100644 --- a/platforms/pictique/api/src/controllers/UserController.ts +++ b/platforms/pictique/api/src/controllers/UserController.ts @@ -20,7 +20,7 @@ export class UserController { return res.status(400).json({ error: "User ID is required" }); } - const profile = await this.userService.getProfileById(id); + const profile = await this.userService.getProfileById(id, req.user?.id); if (!profile) { return res.status(404).json({ error: "User not found" }); } diff --git a/platforms/pictique/api/src/database/entities/Chat.ts b/platforms/pictique/api/src/database/entities/Chat.ts index c44f9b193..5ba3adf43 100644 --- a/platforms/pictique/api/src/database/entities/Chat.ts +++ b/platforms/pictique/api/src/database/entities/Chat.ts @@ -22,7 +22,7 @@ export class Chat { @OneToMany(() => Message, (e) => e.chat) messages!: Message[]; - @ManyToMany(() => User) + @ManyToMany(() => User, (user) => user.chats) @JoinTable({ name: "chat_participants", joinColumn: { name: "chat_id", referencedColumnName: "id" }, diff --git a/platforms/pictique/api/src/services/UserService.ts b/platforms/pictique/api/src/services/UserService.ts index f465354a3..ed3340887 100644 --- a/platforms/pictique/api/src/services/UserService.ts +++ b/platforms/pictique/api/src/services/UserService.ts @@ -354,43 +354,78 @@ export class UserService { }; followUser = async (followerId: string, followingId: string) => { - const follower = await this.userRepository.findOne({ - where: { id: followerId }, - relations: ["following"], - }); - - const following = await this.userRepository.findOne({ - where: { id: followingId }, - }); - - if (!follower || !following) { - throw new Error("User not found"); + if (followerId === followingId) { + throw new Error("Cannot follow yourself"); } - if (!follower.following) { - follower.following = []; - } + // Both sides are read, checked, and (if needed) written inside one + // transaction so a failure partway through can't leave one join + // table updated and the other not. + await this.userRepository.manager.transaction(async (manager) => { + const follower = await manager.findOne(User, { + where: { id: followerId }, + relations: ["following"], + }); + + const following = await manager.findOne(User, { + where: { id: followingId }, + relations: ["followers"], + }); + + if (!follower || !following) { + throw new Error("User not found"); + } + + if (!follower.following) { + follower.following = []; + } + if (!following.followers) { + following.followers = []; + } + + // The two sides are backed by separate join tables and can drift + // out of sync (e.g. a prior write that only landed on one side), + // so each side is checked and repaired independently rather than + // assuming one side being present implies the other is too. + const missingFromFollowing = !follower.following.some( + (user) => user.id === followingId, + ); + const missingFromFollowers = !following.followers.some( + (user) => user.id === followerId, + ); - // Check if already following - if (follower.following.some((user) => user.id === followingId)) { - return follower; - } + if (missingFromFollowing) { + follower.following.push(following); + await manager.save(follower); + } + if (missingFromFollowers) { + following.followers.push(follower); + await manager.save(following); + } + }); - follower.following.push(following); - return await this.userRepository.save(follower); + // follower.following and following.followers now reference each + // other, so the loaded entities aren't safe to JSON-serialize + // directly. Return the freshly updated target profile instead, + // using the same safe shape getProfileById already builds. + return this.getProfileById(followingId, followerId); }; - async getProfileById(userId: string) { + async getProfileById(userId: string, viewerId?: string) { const user = await this.userRepository.findOne({ where: { id: userId }, + relations: { + followers: true, + following: true, + }, select: { id: true, handle: true, name: true, avatarUrl: true, - followers: true, - following: true, description: true, + followers: { id: true }, + following: { id: true }, }, }); @@ -402,8 +437,17 @@ export class UserService { order: { createdAt: "DESC" }, }); + const followers = user.followers ?? []; + const following = user.following ?? []; + return { ...user, + username: user.handle, + followers: followers.length, + following: following.length, + isFollowing: viewerId + ? followers.some((follower) => follower.id === viewerId) + : false, totalPosts: posts.length, posts: posts.map((post) => ({ id: post.id, diff --git a/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts b/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts index 96fe22ef3..0898e0098 100644 --- a/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts @@ -336,7 +336,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { setTimeout(async () => { try { let globalId = await this.adapter.mappingDb.getGlobalId( - entity.id + parentEntity.id ); globalId = globalId ?? ""; @@ -348,7 +348,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { console.log( "sending packet for global Id", globalId, - entity.id + parentEntity.id ); const tableName = `${junctionInfo.entity.toLowerCase()}s`; diff --git a/platforms/pictique/client/src/lib/fragments/Profile/Profile.svelte b/platforms/pictique/client/src/lib/fragments/Profile/Profile.svelte index 02a1fc252..389529321 100644 --- a/platforms/pictique/client/src/lib/fragments/Profile/Profile.svelte +++ b/platforms/pictique/client/src/lib/fragments/Profile/Profile.svelte @@ -12,16 +12,14 @@ handleFollow, handleSinglePost, handleMessage, - isFollowing = $bindable(false), - didFollowed = $bindable(false) + followPending = false }: { variant: 'user' | 'other'; profileData: userProfile; handleSinglePost: (post: PostData) => void; handleFollow: () => Promise; handleMessage: () => Promise; - isFollowing: boolean; - didFollowed: boolean; + followPending?: boolean; } = $props(); let imgPosts = $derived(profileData.posts.filter((e) => e.imgUris && e.imgUris.length > 0)); @@ -59,15 +57,15 @@ variant={'primary'} size="sm" callback={wrappedFollow} - class="min-w-[110px] transition-all duration-500 {didFollowed + class="min-w-[110px] transition-all duration-500 {profileData.isFollowing ? 'opacity-80' : ''}" >
- {#if didFollowed} + {#if profileData.isFollowing} Following - {:else if isFollowing} + {:else if followPending} . . @@ -96,11 +94,11 @@

Posts

-

{0}

+

{profileData.followers}

Followers

-

{0}

+

{profileData.following}

Following

diff --git a/platforms/pictique/client/src/lib/stores/users.ts b/platforms/pictique/client/src/lib/stores/users.ts index d230bc893..11c4e11b7 100644 --- a/platforms/pictique/client/src/lib/stores/users.ts +++ b/platforms/pictique/client/src/lib/stores/users.ts @@ -46,7 +46,7 @@ export const searchUsers = async (query: string) => { export const followUser = async (followingId: string) => { try { - await apiClient.post('/api/users/follow', { followingId }); + await apiClient.post(`/api/users/${followingId}/follow`); return true; } catch (err) { console.error('Failed to follow user:', err); diff --git a/platforms/pictique/client/src/lib/types.ts b/platforms/pictique/client/src/lib/types.ts index 4ff512dcd..cc95f81e7 100644 --- a/platforms/pictique/client/src/lib/types.ts +++ b/platforms/pictique/client/src/lib/types.ts @@ -66,6 +66,7 @@ export type userProfile = { totalPosts: number; followers: number; following: number; + isFollowing: boolean; posts: PostData[]; username: string; }; diff --git a/platforms/pictique/client/src/routes/(protected)/profile/[id]/+page.svelte b/platforms/pictique/client/src/routes/(protected)/profile/[id]/+page.svelte index a19ed7ef2..9d18151a0 100644 --- a/platforms/pictique/client/src/routes/(protected)/profile/[id]/+page.svelte +++ b/platforms/pictique/client/src/routes/(protected)/profile/[id]/+page.svelte @@ -27,8 +27,7 @@ let error = $state(null); let loading = $state(true); let ownerId: string | null = $derived(getAuthId()); - let isFollowing = $state(false); - let didFollowed = $state(false); + let followPending = $state(false); let ownerProfile = $derived.by(async () => { if (ownerId) { const response = await apiClient.get(`/api/users/${ownerId}`); @@ -51,21 +50,15 @@ } async function handleFollow() { + if (!profile || profile.isFollowing || followPending) return; + followPending = true; try { - isFollowing = true; - const response = await apiClient.post(`/api/users/${profileId}/follow`); - if (response) { - didFollowed = true; - setTimeout(async () => { - await fetchProfile(); - didFollowed = false; - }, 1000); - } + const response = await apiClient.post(`/api/users/${profileId}/follow`); + profile = response.data; } catch (err) { error = err instanceof Error ? err.message : 'Failed to follow user'; - didFollowed = false; } finally { - isFollowing = false; + followPending = false; } } @@ -106,8 +99,7 @@ {:else if profile} handlePostClick(post)}