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);