From 891b29f607dc06a7c53587d2c2b2967a6fb43215 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 7 Apr 2026 22:55:01 -0500 Subject: [PATCH] Publish prop.created event when a prop is added to a competition Notifies all competition members (except the creator) via Pub/Sub. Member lookup happens outside the transaction to avoid breaking existing test mocks. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/db_actions/props.test.ts | 6 +++++ lib/db_actions/props.ts | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/lib/db_actions/props.test.ts b/lib/db_actions/props.test.ts index f4f0e2d..20dee1b 100644 --- a/lib/db_actions/props.test.ts +++ b/lib/db_actions/props.test.ts @@ -3,6 +3,12 @@ import * as getUser from "@/lib/get-user"; import * as dbHelpers from "@/lib/db-helpers"; // Mock dependencies +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/pubsub/client", () => ({ + publishEvent: vi.fn().mockResolvedValue("msg-mock"), +})); + vi.mock("@/lib/get-user", () => ({ getUserFromCookies: vi.fn(), })); diff --git a/lib/db_actions/props.ts b/lib/db_actions/props.ts index 5fbfe95..e10ed01 100644 --- a/lib/db_actions/props.ts +++ b/lib/db_actions/props.ts @@ -11,6 +11,7 @@ import { } from "@/lib/server-action-result"; import { logger } from "@/lib/logger"; import { withRLS, withRLSAction } from "@/lib/db-helpers"; +import { publishEvent } from "@/lib/pubsub/client"; export async function getPropById( propId: number, @@ -488,6 +489,15 @@ export async function createProp({ revalidatePath("/props"); if (prop.competition_id) { revalidatePath(`/competitions/${prop.competition_id}`); + + // Notify competition members about the new prop (fire-and-forget) + notifyPropCreated(currentUser.id, prop.competition_id, prop.text).catch( + (err) => { + logger.error("Failed to publish prop.created event", err as Error, { + competitionId: prop.competition_id, + }); + }, + ); } } @@ -593,3 +603,44 @@ export async function deleteProp({ return error("Failed to delete prop", ERROR_CODES.DATABASE_ERROR); } } + +async function notifyPropCreated( + creatorUserId: number, + competitionId: number, + propText: string, +): Promise { + const comp = await withRLS(undefined, async (trx) => { + return trx + .selectFrom("competitions") + .select("name") + .where("id", "=", competitionId) + .executeTakeFirst(); + }); + + if (!comp) return; + + const members = await withRLS(undefined, async (trx) => { + return trx + .selectFrom("competition_members") + .innerJoin("users", "users.id", "competition_members.user_id") + .select(["users.name", "users.email"]) + .where("competition_members.competition_id", "=", competitionId) + .where("users.id", "!=", creatorUserId) + .execute(); + }); + + if (members.length === 0) return; + + await publishEvent({ + event_type: "prop.created", + source: "forecasting", + timestamp: new Date().toISOString(), + notify: members.map((m) => ({ email: m.email, name: m.name })), + notify_link: `${process.env.APP_BASE_URL}/competitions/${competitionId}`, + data: { + competition_name: comp.name, + competition_id: competitionId, + prop_text: propText, + }, + }); +}