From 199875871343f460a62f602dfe8f3711426f8a7d Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:12:07 +0200 Subject: [PATCH] fix(timeline): a post could be liked and disliked at once, with a count behind neither MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found sweeping the timeline in production. Like a post, then dislike it: both buttons render as active, and the like count keeps a number that has no row behind it. The two reactions are meant to be exclusive and the RPCs enforce it — liking DELETEs any dislike row, and the reverse. What neither did was recompute the count belonging to the reaction it had just removed. So: timeline_likes no row for (event, user) ← correctly deleted timeline_event_stats like_count = 1 ← never recomputed dislike_count = 1 That number is wrong for every reader, not just the person who clicked, and it stays wrong: nothing recomputes these except the next reaction on the same post. The UI reads timeline_event_stats, so the post shows a like nobody has given. All four functions now recount BOTH totals whenever either changes, and return both. Toggle-off cannot move the opposite count, but returns it anyway so all four responses have one shape — a caller that has to remember which of four carries which field will eventually get it wrong. The existing skewed rows are recounted from the membership tables, which were right the whole time; only the cache drifted. DROP + CREATE was forced by the changed return type, so the grants are restored explicitly rather than left to the default: a reaction that silently stopped being callable by `authenticated` would look exactly like the bug being fixed. The client half was the same mistake from the other side. handleLike only ever wrote userLiked/likesCount, so it left the opposite button lit and its count untouched. It now clears the opposite optimistically and then takes both totals from the response instead of inferring them. A failed switch restores both sides rather than leaving the post with neither. Rehearsed against production inside a transaction and rolled back: like → (1,0), dislike → (0,1), rows and cached stats agree, and the repair statement corrected one already-skewed row. Proven by mutation: dropping the opposite-clearing fails the switch-sides test and only that one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- .../unit/components/cat-reply-badge.test.tsx | 61 ++++++ .../unit/hooks/usePostLikeDislike.test.ts | 138 +++++++++++++ .../unit/services/reaction-count.test.ts | 6 +- src/components/timeline/PostHeader.tsx | 21 ++ src/hooks/usePostLikeDislike.ts | 77 +++++++- src/services/timeline/index.ts | 11 +- src/services/timeline/processors/reactions.ts | 167 ++++++++++++---- ...actions_leave_the_opposite_count_stale.sql | 181 ++++++++++++++++++ 8 files changed, 604 insertions(+), 58 deletions(-) create mode 100644 __tests__/unit/components/cat-reply-badge.test.tsx create mode 100644 __tests__/unit/hooks/usePostLikeDislike.test.ts create mode 100644 supabase/migrations/20260828180000_reactions_leave_the_opposite_count_stale.sql diff --git a/__tests__/unit/components/cat-reply-badge.test.tsx b/__tests__/unit/components/cat-reply-badge.test.tsx new file mode 100644 index 000000000..116d0002f --- /dev/null +++ b/__tests__/unit/components/cat-reply-badge.test.tsx @@ -0,0 +1,61 @@ +/** + * A reply written by the agent has to say so. + * + * `is_cat_reply: true` was stamped onto every Cat reply, under a comment saying + * it was "marked so the UI can render a Cat reply distinctly rather than + * leaving a reader to work out from the avatar that this one was written by an + * agent". Nothing read it — two writers, zero readers — so an answer from the + * Cat looked exactly like an answer from a person, unless you happened to + * recognise the handle. + * + * That is the difference that matters when copying how @grok works: the model's + * replies are visibly the model's. Attribution of machine-written text to a + * human reader is not a styling detail. + */ + +import { render, screen } from '@testing-library/react'; +import { PostHeader } from '@/components/timeline/PostHeader'; +import type { TimelineDisplayEvent } from '@/types/timeline'; + +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +function postBy( + username: string, + extra: Partial = {} +): TimelineDisplayEvent { + return { + id: 'e1', + actor: { id: 'a1', name: username === 'cat' ? 'Cat' : 'A Person', username, type: 'user' }, + eventTimestamp: new Date().toISOString(), + ...extra, + } as unknown as TimelineDisplayEvent; +} + +describe('a Cat reply is labelled', () => { + it('marks a post authored by the Cat', () => { + render(); + expect(screen.getByText('AI')).toBeInTheDocument(); + }); + + it('marks a reply flagged as the Cat even if attribution changes', () => { + render(); + expect(screen.getByText('AI')).toBeInTheDocument(); + }); + + it('leaves an ordinary person unmarked', () => { + render(); + expect(screen.queryByText('AI')).not.toBeInTheDocument(); + }); + + it('is not fooled by a handle that merely starts with cat', () => { + // `catalogue` must not be badged as the platform agent — the same rule the + // mention menu applies when deciding what @cat means. + render(); + expect(screen.queryByText('AI')).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/unit/hooks/usePostLikeDislike.test.ts b/__tests__/unit/hooks/usePostLikeDislike.test.ts new file mode 100644 index 000000000..705916e1b --- /dev/null +++ b/__tests__/unit/hooks/usePostLikeDislike.test.ts @@ -0,0 +1,138 @@ +/** + * Liking and disliking are mutually exclusive, and the UI has to say so. + * + * The RPCs delete the opposing row when you switch sides. The client did not + * model that: `handleLike` only ever wrote `userLiked`/`likesCount`, so after + * liking a post and then disliking it, BOTH buttons rendered as active and the + * like count kept a number with no row behind it. + * + * Observed in production 2026-08-28 on orangecat.ch/timeline: like → dislike + * left `aria-pressed="true"` on both, and `timeline_event_stats` held + * `like_count: 1, dislike_count: 1` for an event with no like row at all. + */ + +import { renderHook, act } from '@testing-library/react'; +import { usePostLikeDislike } from '@/hooks/usePostLikeDislike'; +import type { TimelineDisplayEvent } from '@/types/timeline'; + +const toggleLike = jest.fn(); +const toggleDislike = jest.fn(); +jest.mock('@/services/timeline', () => ({ + timelineService: { + toggleLike: (...a: unknown[]) => toggleLike(...a), + toggleDislike: (...a: unknown[]) => toggleDislike(...a), + }, +})); + +function eventWith(overrides: Partial): TimelineDisplayEvent { + return { + id: 'e1', + userLiked: false, + likesCount: 0, + userDisliked: false, + dislikesCount: 0, + ...overrides, + } as TimelineDisplayEvent; +} + +describe('liking and disliking are exclusive', () => { + beforeEach(() => jest.clearAllMocks()); + + it('clears the like when you dislike a post you had liked', async () => { + const onUpdate = jest.fn(); + toggleDislike.mockResolvedValue({ + success: true, + disliked: true, + dislikeCount: 1, + liked: false, + likeCount: 0, + }); + + const { result } = renderHook(() => + usePostLikeDislike({ + event: eventWith({ userLiked: true, likesCount: 1 }), + onUpdate, + }) + ); + + await act(async () => { + await result.current.handleDislike(); + }); + + // Optimistic pass: the like goes immediately, not after the round trip. + expect(onUpdate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ userDisliked: true, userLiked: false, likesCount: 0 }) + ); + // Server pass: both totals come from the response. + expect(onUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ userDisliked: true, dislikesCount: 1, userLiked: false, likesCount: 0 }) + ); + }); + + it('clears the dislike when you like a post you had disliked', async () => { + const onUpdate = jest.fn(); + toggleLike.mockResolvedValue({ + success: true, + liked: true, + likeCount: 1, + disliked: false, + dislikeCount: 0, + }); + + const { result } = renderHook(() => + usePostLikeDislike({ + event: eventWith({ userDisliked: true, dislikesCount: 1 }), + onUpdate, + }) + ); + + await act(async () => { + await result.current.handleLike(); + }); + + expect(onUpdate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ userLiked: true, userDisliked: false, dislikesCount: 0 }) + ); + }); + + it('leaves the opposite alone when simply un-liking', async () => { + const onUpdate = jest.fn(); + toggleLike.mockResolvedValue({ success: true, liked: false, likeCount: 0, dislikeCount: 0 }); + + const { result } = renderHook(() => + usePostLikeDislike({ event: eventWith({ userLiked: true, likesCount: 1 }), onUpdate }) + ); + + await act(async () => { + await result.current.handleLike(); + }); + + // Un-liking retracts nothing, so `disliked` is absent from the response and + // must not be invented here. + const optimistic = onUpdate.mock.calls[0][0]; + expect(optimistic).not.toHaveProperty('userDisliked'); + }); + + it('restores both sides when the server refuses', async () => { + const onUpdate = jest.fn(); + toggleDislike.mockResolvedValue({ success: false, disliked: false, dislikeCount: 0 }); + + const { result } = renderHook(() => + usePostLikeDislike({ + event: eventWith({ userLiked: true, likesCount: 1 }), + onUpdate, + }) + ); + + await act(async () => { + await result.current.handleDislike(); + }); + + // A failed switch must put the like back, not leave the post with neither. + expect(onUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ userDisliked: false, userLiked: true, likesCount: 1 }) + ); + }); +}); diff --git a/__tests__/unit/services/reaction-count.test.ts b/__tests__/unit/services/reaction-count.test.ts index e6a4aad9e..b1489d774 100644 --- a/__tests__/unit/services/reaction-count.test.ts +++ b/__tests__/unit/services/reaction-count.test.ts @@ -47,7 +47,11 @@ describe('reaction counts', () => { it('reads the count out of the array PostgREST returns for RETURNS TABLE', async () => { rpc.mockResolvedValue({ data: [{ like_count: 7 }], error: null }); - await expect(toggleLike('event-1', 'user-1')).resolves.toEqual({ + // toMatchObject, not toEqual: the response also carries the OPPOSITE + // reaction's state now, because a like retracts a dislike. This test is + // about reading the count out of PostgREST's array shape, not about the + // full result envelope. + await expect(toggleLike('event-1', 'user-1')).resolves.toMatchObject({ success: true, liked: true, likeCount: 7, diff --git a/src/components/timeline/PostHeader.tsx b/src/components/timeline/PostHeader.tsx index 311841995..a6c6fa817 100644 --- a/src/components/timeline/PostHeader.tsx +++ b/src/components/timeline/PostHeader.tsx @@ -6,6 +6,8 @@ import { MoreHorizontal, Lock, Users, Pencil, Trash2 } from 'lucide-react'; import { TimelineDisplayEvent } from '@/types/timeline'; import { formatRelativeTime } from '@/utils/dates'; import { TIMELINE_SURFACE } from '@/config/timeline'; +import { CAT_USERNAME } from '@/config/cat-identity'; +import { normalizeUsername } from '@/config/usernames'; interface PostHeaderProps { event: TimelineDisplayEvent; @@ -67,6 +69,10 @@ export function PostHeader({ avatar: rawAuthor?.avatar, }; + const isCatAuthored = + normalizeUsername(displayAuthor.username || '') === normalizeUsername(CAT_USERNAME) || + event.metadata?.is_cat_reply === true; + // TimelineDisplayEvent extends TimelineEvent which has eventTimestamp, createdAt, updatedAt // Use eventTimestamp as primary, fallback to createdAt for backward compatibility const timestamp = event.eventTimestamp || event.createdAt; @@ -84,6 +90,21 @@ export function PostHeader({ {displayAuthor.name} + {/* Written by the platform's agent, said out loud. + `is_cat_reply` was already being written onto every Cat reply, with a + comment saying it existed "so the UI can render a Cat reply + distinctly" — and nothing read it. Two writers, no reader, so a Cat + answer was indistinguishable from a person's unless you recognised the + handle. + The author is the primary signal because it is the fact that matters + (this was written by the agent); the metadata flag is honoured too so + a reply stays marked even if it is ever re-attributed. */} + {isCatAuthored && ( + + AI + + )} + { if (isDisliking) { @@ -59,15 +91,36 @@ export function usePostLikeDislike({ event, onUpdate }: Props): UsePostLikeDisli const nextDisliked = !originalDisliked; const nextCount = Math.max(0, originalCount + (nextDisliked ? 1 : -1)); - onUpdate({ userDisliked: nextDisliked, dislikesCount: nextCount }); + // Mirror image of handleLike: a dislike retracts a like. + const wasLiked = !!event.userLiked; + onUpdate({ + userDisliked: nextDisliked, + dislikesCount: nextCount, + ...(nextDisliked && wasLiked + ? { + userLiked: false, + likesCount: Math.max(0, (event.likesCount || 0) - 1), + } + : {}), + }); setIsDisliking(true); try { const result = await timelineService.toggleDislike(event.id); if (result.success) { - onUpdate({ userDisliked: result.disliked, dislikesCount: result.dislikeCount }); + onUpdate({ + userDisliked: result.disliked, + dislikesCount: result.dislikeCount, + ...(result.liked !== undefined ? { userLiked: result.liked } : {}), + ...(result.likeCount !== undefined ? { likesCount: result.likeCount } : {}), + }); } else { - onUpdate({ userDisliked: originalDisliked, dislikesCount: originalCount }); + onUpdate({ + userDisliked: originalDisliked, + dislikesCount: originalCount, + userLiked: wasLiked, + likesCount: event.likesCount || 0, + }); } } catch (error) { logger.error('Failed to toggle dislike', error, 'usePostLikeDislike'); @@ -75,7 +128,15 @@ export function usePostLikeDislike({ event, onUpdate }: Props): UsePostLikeDisli } finally { setIsDisliking(false); } - }, [event.id, event.userDisliked, event.dislikesCount, isDisliking, onUpdate]); + }, [ + event.id, + event.userDisliked, + event.dislikesCount, + event.userLiked, + event.likesCount, + isDisliking, + onUpdate, + ]); return { isLiking, handleLike, isDisliking, handleDislike }; } diff --git a/src/services/timeline/index.ts b/src/services/timeline/index.ts index 7816f620d..7c0706b4b 100644 --- a/src/services/timeline/index.ts +++ b/src/services/timeline/index.ts @@ -63,6 +63,7 @@ import { getEventComments, getCommentReplies, } from './processors/socialInteractions'; +import type { ToggleLikeResult, ToggleDislikeResult } from './processors/reactions'; // Import utilities import { getDemoTimelineEvents } from './utils/demo'; @@ -273,20 +274,14 @@ class TimelineService { /** * Like or unlike an event */ - async toggleLike( - eventId: string, - userId?: string - ): Promise<{ success: boolean; liked: boolean; likeCount: number; error?: string }> { + async toggleLike(eventId: string, userId?: string): Promise { return toggleLikeEvent(eventId, userId); } /** * Toggle dislike on a timeline event (for scam detection and wisdom of crowds) */ - async toggleDislike( - eventId: string, - userId?: string - ): Promise<{ success: boolean; disliked: boolean; dislikeCount: number; error?: string }> { + async toggleDislike(eventId: string, userId?: string): Promise { return toggleDislikeEvent(eventId, userId); } diff --git a/src/services/timeline/processors/reactions.ts b/src/services/timeline/processors/reactions.ts index 7fa662190..9d230e0e4 100644 --- a/src/services/timeline/processors/reactions.ts +++ b/src/services/timeline/processors/reactions.ts @@ -1,6 +1,9 @@ /** - * Timeline reactions (likes / dislikes). Extracted verbatim from - * socialInteractions.ts (SoC) and re-exported from it. No behavior change. + * Timeline reactions (likes / dislikes). + * + * Liking and disliking are mutually exclusive: the RPCs delete the opposing row + * when you switch. That makes a single reaction a change to BOTH totals, which + * is why everything here carries both counts rather than the one it asked for. */ import { logger } from '@/utils/logger'; @@ -12,24 +15,36 @@ interface ReactionConfig { table: string; addRpc: string; removeRpc: string; - /** Key in the RPC response containing the updated count, e.g. 'like_count' */ + /** Key in the RPC response containing this reaction's count, e.g. 'like_count' */ countKey: string; + /** The other reaction's count key, which the same response also carries. */ + oppositeCountKey: string; +} + +interface ReactionResult { + success: boolean; + /** Whether this reaction is now set for this user. */ + active: boolean; + /** This reaction's new total. */ + count: number; + /** The OPPOSITE reaction's new total — it moves when you switch sides. */ + oppositeCount: number; + error?: string; } /** - * Read the new count out of an RPC response. + * Read a count out of an RPC response. * - * All four of these functions are `RETURNS TABLE(_count integer)`, and - * PostgREST renders a set-returning function as an ARRAY of rows — `[{ - * like_count: 1 }]`. This used to index the array as if it were the row, so the - * lookup was always undefined and the `|| 0` turned every successful reaction - * into a count of zero. + * All four functions are `RETURNS TABLE(...)`, and PostgREST renders a + * set-returning function as an ARRAY of rows — `[{ like_count: 1 }]`. This used + * to index the array as if it were the row, so the lookup was always undefined + * and the `|| 0` turned every successful reaction into a count of zero. * - * The effect was subtle enough to survive the whole time the RPCs were also - * raising 42703: liking something persisted correctly and then rendered as if - * nobody had, because the button state comes from `active` (a literal) while - * the number comes from here. Accepts either shape, so it cannot break again if - * one of these is ever rewritten to return a scalar. + * The effect survived the whole time the RPCs were also raising 42703: liking + * something persisted correctly and then rendered as if nobody had, because the + * button state comes from `active` (a literal) while the number comes from + * here. Accepts either shape, so a future rewrite to a scalar cannot silently + * zero it. */ function readCount(data: unknown, countKey: string): number { const row = Array.isArray(data) ? data[0] : data; @@ -40,12 +55,41 @@ function readCount(data: unknown, countKey: string): number { return typeof value === 'number' ? value : 0; } +/** + * Count a reaction table directly. + * + * Only used by the fallback paths below, where the RPC was unavailable and + * there is no response to read counts out of. Counting the opposite table + * costs one more query on a path that already failed once — cheaper than + * returning a number the caller will render as truth. + */ +async function countFor(table: string, eventId: string): Promise { + const { count } = await db + .from(table) + .select('*', { count: 'exact', head: true }) + .eq('event_id', eventId); + return count || 0; +} + +function oppositeTable(table: string): string { + return table === DATABASE_TABLES.TIMELINE_LIKES + ? DATABASE_TABLES.TIMELINE_DISLIKES + : DATABASE_TABLES.TIMELINE_LIKES; +} + async function toggleReaction( eventId: string, targetUserId: string, cfg: ReactionConfig -): Promise<{ success: boolean; active: boolean; count: number; error?: string }> { - const { table, addRpc, removeRpc, countKey } = cfg; +): Promise { + const { table, addRpc, removeRpc, countKey, oppositeCountKey } = cfg; + const failed = (error: string): ReactionResult => ({ + success: false, + active: false, + count: 0, + oppositeCount: 0, + error, + }); const { data: existing } = await db .from(table) @@ -63,12 +107,13 @@ async function toggleReaction( }); if (error) { logger.error(`Failed to call ${removeRpc}`, error, 'Timeline'); - return { success: false, active: false, count: 0, error: error.message }; + return failed(error.message); } return { success: true, active: false, count: readCount(data, countKey), + oppositeCount: readCount(data, oppositeCountKey), }; } catch (dbError) { logger.warn(`RPC ${removeRpc} not available, using fallback`, dbError, 'Timeline'); @@ -79,13 +124,14 @@ async function toggleReaction( .eq('user_id', targetUserId); if (delErr) { logger.error(`Fallback ${removeRpc} failed`, delErr, 'Timeline'); - return { success: false, active: false, count: 0, error: delErr.message }; + return failed(delErr.message); } - const { count } = await db - .from(table) - .select('*', { count: 'exact', head: true }) - .eq('event_id', eventId); - return { success: true, active: false, count: count || 0 }; + return { + success: true, + active: false, + count: await countFor(table, eventId), + oppositeCount: await countFor(oppositeTable(table), eventId), + }; } } else { // Add reaction @@ -96,12 +142,13 @@ async function toggleReaction( }); if (error) { logger.error(`Failed to call ${addRpc}`, error, 'Timeline'); - return { success: false, active: false, count: 0, error: error.message }; + return failed(error.message); } return { success: true, active: true, count: readCount(data, countKey), + oppositeCount: readCount(data, oppositeCountKey), }; } catch (dbError) { logger.warn(`RPC ${addRpc} not available, using fallback`, dbError, 'Timeline'); @@ -110,24 +157,47 @@ async function toggleReaction( .insert({ event_id: eventId, user_id: targetUserId }); if (insertErr) { logger.error(`Fallback ${addRpc} failed`, insertErr, 'Timeline'); - return { success: false, active: false, count: 0, error: insertErr.message }; + return failed(insertErr.message); } - const { count } = await db - .from(table) - .select('*', { count: 'exact', head: true }) - .eq('event_id', eventId); - return { success: true, active: true, count: count || 0 }; + // The fallback INSERT does not retract the opposite reaction the way the + // RPC does, so the opposite count is read rather than assumed. + return { + success: true, + active: true, + count: await countFor(table, eventId), + oppositeCount: await countFor(oppositeTable(table), eventId), + }; } } } +export interface ToggleLikeResult { + success: boolean; + liked: boolean; + likeCount: number; + /** Set when the server retracted a dislike as a result of this like. */ + disliked?: boolean; + dislikeCount?: number; + error?: string; +} + +export interface ToggleDislikeResult { + success: boolean; + disliked: boolean; + dislikeCount: number; + liked?: boolean; + likeCount?: number; + error?: string; +} + /** - * Like or unlike an event + * Like or unlike an event. + * + * Reports what happened to the DISLIKE as well. Liking retracts a dislike + * server-side, and a caller that has to infer that gets it wrong — which is + * how a post came to render as liked and disliked at the same time. */ -export async function toggleLike( - eventId: string, - userId?: string -): Promise<{ success: boolean; liked: boolean; likeCount: number; error?: string }> { +export async function toggleLike(eventId: string, userId?: string): Promise { try { return await withApiRetry( async () => { @@ -140,8 +210,17 @@ export async function toggleLike( addRpc: 'like_timeline_event', removeRpc: 'unlike_timeline_event', countKey: 'like_count', + oppositeCountKey: 'dislike_count', }); - return { success: r.success, liked: r.active, likeCount: r.count, error: r.error }; + return { + success: r.success, + liked: r.active, + likeCount: r.count, + // Only a like retracts a dislike; un-liking leaves it untouched. + disliked: r.active ? false : undefined, + dislikeCount: r.oppositeCount, + error: r.error, + }; }, { maxAttempts: 2 } // Only retry once for likes to avoid spam ); @@ -151,13 +230,11 @@ export async function toggleLike( } } -/** - * Toggle dislike on a timeline event (for scam detection and wisdom of crowds) - */ +/** Toggle dislike on a timeline event (for scam detection and wisdom of crowds). */ export async function toggleDislike( eventId: string, userId?: string -): Promise<{ success: boolean; disliked: boolean; dislikeCount: number; error?: string }> { +): Promise { try { const targetUserId = userId || (await getCurrentUserId()); if (!targetUserId) { @@ -168,8 +245,16 @@ export async function toggleDislike( addRpc: 'dislike_timeline_event', removeRpc: 'undislike_timeline_event', countKey: 'dislike_count', + oppositeCountKey: 'like_count', }); - return { success: r.success, disliked: r.active, dislikeCount: r.count, error: r.error }; + return { + success: r.success, + disliked: r.active, + dislikeCount: r.count, + liked: r.active ? false : undefined, + likeCount: r.oppositeCount, + error: r.error, + }; } catch (error) { logger.error('Error toggling dislike on timeline event', error, 'Timeline'); return { success: false, disliked: false, dislikeCount: 0, error: 'Internal server error' }; diff --git a/supabase/migrations/20260828180000_reactions_leave_the_opposite_count_stale.sql b/supabase/migrations/20260828180000_reactions_leave_the_opposite_count_stale.sql new file mode 100644 index 000000000..9b1c5a1ba --- /dev/null +++ b/supabase/migrations/20260828180000_reactions_leave_the_opposite_count_stale.sql @@ -0,0 +1,181 @@ +-- Liking a post you disliked leaves the dislike count claiming you still do. +-- +-- `like_timeline_event` deletes any existing dislike row (the two are meant to +-- be exclusive) and then recomputes and stores ONLY `like_count`. +-- `dislike_timeline_event` is the mirror image. So the count belonging to the +-- reaction that was just removed keeps whatever value it had. +-- +-- Measured in production 2026-08-28 by liking a post and then disliking it: +-- +-- timeline_likes no row for (event, user) ← correctly deleted +-- timeline_event_stats like_count = 1 ← never recomputed +-- dislike_count = 1 +-- +-- The number is wrong for everyone, not just the person who clicked, and stays +-- wrong: nothing recomputes these except the next reaction on the same post. +-- The UI reads `timeline_event_stats`, so the post shows one like that does not +-- exist. +-- +-- The fix is to recompute BOTH counts whenever either changes, because either +-- change can move both. Each function now returns both, so the client can stop +-- inferring the other one — see the matching change in usePostLikeDislike, +-- which used to leave the opposite button lit because the response never +-- mentioned it. +-- +-- Toggle-off (unlike/undislike) cannot affect the opposite count, but they +-- return both for one shape across all four: a caller that has to remember +-- which of four responses carries which field will eventually get it wrong. + +DROP FUNCTION IF EXISTS public.like_timeline_event(uuid, uuid); +CREATE FUNCTION public.like_timeline_event(p_event_id uuid, p_user_id uuid) + RETURNS TABLE(like_count integer, dislike_count integer) + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_likes INTEGER; + v_dislikes INTEGER; +BEGIN + IF auth.uid() != p_user_id THEN + RAISE EXCEPTION 'Unauthorized'; + END IF; + + INSERT INTO timeline_likes (event_id, user_id) + VALUES (p_event_id, p_user_id) + ON CONFLICT (event_id, user_id) DO NOTHING; + + -- A like retracts a dislike. Both totals move, so both are recounted. + DELETE FROM timeline_dislikes + WHERE event_id = p_event_id AND user_id = p_user_id; + + SELECT COUNT(*)::INTEGER INTO v_likes FROM timeline_likes WHERE event_id = p_event_id; + SELECT COUNT(*)::INTEGER INTO v_dislikes FROM timeline_dislikes WHERE event_id = p_event_id; + + INSERT INTO timeline_event_stats (event_id, like_count, dislike_count, updated_at) + VALUES (p_event_id, v_likes, v_dislikes, NOW()) + ON CONFLICT (event_id) DO UPDATE + SET like_count = v_likes, dislike_count = v_dislikes, updated_at = NOW(); + + RETURN QUERY SELECT v_likes, v_dislikes; +END; +$function$; + +DROP FUNCTION IF EXISTS public.dislike_timeline_event(uuid, uuid); +CREATE FUNCTION public.dislike_timeline_event(p_event_id uuid, p_user_id uuid) + RETURNS TABLE(like_count integer, dislike_count integer) + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_likes INTEGER; + v_dislikes INTEGER; +BEGIN + IF auth.uid() != p_user_id THEN + RAISE EXCEPTION 'Unauthorized'; + END IF; + + INSERT INTO timeline_dislikes (event_id, user_id) + VALUES (p_event_id, p_user_id) + ON CONFLICT (event_id, user_id) DO NOTHING; + + DELETE FROM timeline_likes + WHERE event_id = p_event_id AND user_id = p_user_id; + + SELECT COUNT(*)::INTEGER INTO v_likes FROM timeline_likes WHERE event_id = p_event_id; + SELECT COUNT(*)::INTEGER INTO v_dislikes FROM timeline_dislikes WHERE event_id = p_event_id; + + INSERT INTO timeline_event_stats (event_id, like_count, dislike_count, updated_at) + VALUES (p_event_id, v_likes, v_dislikes, NOW()) + ON CONFLICT (event_id) DO UPDATE + SET like_count = v_likes, dislike_count = v_dislikes, updated_at = NOW(); + + RETURN QUERY SELECT v_likes, v_dislikes; +END; +$function$; + +DROP FUNCTION IF EXISTS public.unlike_timeline_event(uuid, uuid); +CREATE FUNCTION public.unlike_timeline_event(p_event_id uuid, p_user_id uuid) + RETURNS TABLE(like_count integer, dislike_count integer) + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_likes INTEGER; + v_dislikes INTEGER; +BEGIN + IF auth.uid() != p_user_id THEN + RAISE EXCEPTION 'Unauthorized'; + END IF; + + DELETE FROM timeline_likes + WHERE event_id = p_event_id AND user_id = p_user_id; + + SELECT COUNT(*)::INTEGER INTO v_likes FROM timeline_likes WHERE event_id = p_event_id; + SELECT COUNT(*)::INTEGER INTO v_dislikes FROM timeline_dislikes WHERE event_id = p_event_id; + + INSERT INTO timeline_event_stats (event_id, like_count, dislike_count, updated_at) + VALUES (p_event_id, v_likes, v_dislikes, NOW()) + ON CONFLICT (event_id) DO UPDATE + SET like_count = v_likes, dislike_count = v_dislikes, updated_at = NOW(); + + RETURN QUERY SELECT v_likes, v_dislikes; +END; +$function$; + +DROP FUNCTION IF EXISTS public.undislike_timeline_event(uuid, uuid); +CREATE FUNCTION public.undislike_timeline_event(p_event_id uuid, p_user_id uuid) + RETURNS TABLE(like_count integer, dislike_count integer) + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_likes INTEGER; + v_dislikes INTEGER; +BEGIN + IF auth.uid() != p_user_id THEN + RAISE EXCEPTION 'Unauthorized'; + END IF; + + DELETE FROM timeline_dislikes + WHERE event_id = p_event_id AND user_id = p_user_id; + + SELECT COUNT(*)::INTEGER INTO v_likes FROM timeline_likes WHERE event_id = p_event_id; + SELECT COUNT(*)::INTEGER INTO v_dislikes FROM timeline_dislikes WHERE event_id = p_event_id; + + INSERT INTO timeline_event_stats (event_id, like_count, dislike_count, updated_at) + VALUES (p_event_id, v_likes, v_dislikes, NOW()) + ON CONFLICT (event_id) DO UPDATE + SET like_count = v_likes, dislike_count = v_dislikes, updated_at = NOW(); + + RETURN QUERY SELECT v_likes, v_dislikes; +END; +$function$; + +-- DROP + CREATE was forced by the changed return type, and dropping takes the +-- grants with it. Restored explicitly to exactly what these had before +-- (PUBLIC, anon, authenticated, service_role) rather than relying on the +-- default: a reaction that silently stops being callable by `authenticated` +-- would look exactly like the bug this migration is fixing. +GRANT EXECUTE ON FUNCTION public.like_timeline_event(uuid, uuid) TO PUBLIC, anon, authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.dislike_timeline_event(uuid, uuid) TO PUBLIC, anon, authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.unlike_timeline_event(uuid, uuid) TO PUBLIC, anon, authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.undislike_timeline_event(uuid, uuid) TO PUBLIC, anon, authenticated, service_role; + +-- Repair the rows already skewed by this. Recount from the membership tables, +-- which have been right the whole time — only the cache drifted. +UPDATE timeline_event_stats s +SET like_count = c.likes, + dislike_count = c.dislikes, + updated_at = NOW() +FROM ( + SELECT e.id, + (SELECT COUNT(*)::INTEGER FROM timeline_likes l WHERE l.event_id = e.id) AS likes, + (SELECT COUNT(*)::INTEGER FROM timeline_dislikes d WHERE d.event_id = e.id) AS dislikes + FROM timeline_events e +) c +WHERE s.event_id = c.id + AND (s.like_count IS DISTINCT FROM c.likes OR s.dislike_count IS DISTINCT FROM c.dislikes);