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