diff --git a/api/src/party-data.ts b/api/src/party-data.ts index 7b8222a..6070e81 100644 --- a/api/src/party-data.ts +++ b/api/src/party-data.ts @@ -16,6 +16,9 @@ export async function getPartyForUser(userId: string) { where: { userId, }, + orderBy: { + joinedAt: "desc", + }, with: { party: true, }, @@ -77,23 +80,68 @@ export async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) { await dbClient.delete(party).where(eq(party.id, partyId)); } -export async function leaveParty(dbClient: DbLike, userId: string) { - const member = await getMemberRecord(dbClient, userId); - if (!member) return null; - await dbClient.delete(partyMember).where(eq(partyMember.id, member.id)); - const nextHost = await dbClient.query.partyMember.findFirst({ +export type LeavePartyResult = { + affectedPartyIds: string[]; + replacementPartyId: string | null; +}; + +export async function leaveParty( + dbClient: DbLike, + userId: string, + options: { createReplacementParty?: boolean } = {}, +): Promise { + const memberships = await dbClient.query.partyMember.findMany({ where: { - partyId: member.partyId, + userId, }, orderBy: { - joinedAt: "asc", + joinedAt: "desc", }, }); - let newHostId: string | null = null; - if (nextHost) { + if (memberships.length === 0) { + if (!options.createReplacementParty) return null; + const created = await dbClient + .insert(party) + .values({ + status: "created", + hostId: userId, + }) + .returning({ id: party.id }); + const replacementPartyId = created[0]?.id ?? null; + if (replacementPartyId) { + await dbClient.insert(partyMember).values({ + partyId: replacementPartyId, + userId, + }); + } + return { + affectedPartyIds: [], + replacementPartyId, + }; + } + + const affectedPartyIds = [ + ...new Set(memberships.map((member) => member.partyId)), + ]; + await dbClient.delete(partyMember).where(eq(partyMember.userId, userId)); + + for (const partyId of affectedPartyIds) { + const nextHost = await dbClient.query.partyMember.findFirst({ + where: { + partyId, + }, + orderBy: { + joinedAt: "asc", + }, + }); + if (!nextHost) { + await cleanupPartyIfEmpty(dbClient, partyId); + continue; + } + const currentParty = await dbClient.query.party.findFirst({ where: { - id: member.partyId, + id: partyId, }, }); if (currentParty?.hostId === userId) { @@ -103,13 +151,30 @@ export async function leaveParty(dbClient: DbLike, userId: string) { hostId: nextHost.userId, lastUpdated: new Date(), }) - .where(eq(party.id, member.partyId)); - newHostId = nextHost.userId; + .where(eq(party.id, partyId)); } } - await cleanupPartyIfEmpty(dbClient, member.partyId); + + let replacementPartyId: string | null = null; + if (options.createReplacementParty) { + const created = await dbClient + .insert(party) + .values({ + status: "created", + hostId: userId, + }) + .returning({ id: party.id }); + replacementPartyId = created[0]?.id ?? null; + if (replacementPartyId) { + await dbClient.insert(partyMember).values({ + partyId: replacementPartyId, + userId, + }); + } + } + return { - partyId: member.partyId, - newHostId, + affectedPartyIds, + replacementPartyId, }; } diff --git a/api/src/party/__tests__/party-data.test.ts b/api/src/party/__tests__/party-data.test.ts new file mode 100644 index 0000000..b7457db --- /dev/null +++ b/api/src/party/__tests__/party-data.test.ts @@ -0,0 +1,39 @@ +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { db } from "../../db"; +import { partyMember } from "../../db/schema"; +import { getPartyForUser, leaveParty } from "../../party-data"; +import { createParty, createUser, joinParty } from "../../test/factories"; + +describe("party data lifecycle", () => { + it("moves a leaving user to a fresh party and clears stale memberships", async () => { + const user = await createUser("Leave Tester"); + const otherA = await createUser("Other A"); + const otherB = await createUser("Other B"); + + const firstParty = await createParty(otherA.id); + const secondParty = await createParty(otherB.id); + await joinParty(firstParty.partyId, user.id); + await joinParty(secondParty.partyId, user.id); + + const result = await leaveParty(db, user.id, { + createReplacementParty: true, + }); + + expect(result?.affectedPartyIds).toEqual( + expect.arrayContaining([firstParty.partyId, secondParty.partyId]), + ); + expect(result?.replacementPartyId).toBeTruthy(); + + const memberships = await db + .select({ id: partyMember.id, partyId: partyMember.partyId }) + .from(partyMember) + .where(eq(partyMember.userId, user.id)); + + expect(memberships).toHaveLength(1); + expect(memberships[0]?.partyId).toBe(result?.replacementPartyId); + + const currentParty = await getPartyForUser(user.id); + expect(currentParty?.id).toBe(result?.replacementPartyId); + }); +}); diff --git a/api/src/party/state.ts b/api/src/party/state.ts index 838e253..261ccf9 100644 --- a/api/src/party/state.ts +++ b/api/src/party/state.ts @@ -44,6 +44,17 @@ export async function updatePartyData( }; for (const member of members) { if (!member.userId) continue; + pubsub.publish( + `user:${member.userId}`, + JSON.stringify({ + type: "party_status", + party: { + ...partyObject, + data, + }, + members, + }), + ); void publishDeviceEventForUser(member.userId, event); } await db diff --git a/api/src/routes/party.ts b/api/src/routes/party.ts index 1b36285..5e004a8 100644 --- a/api/src/routes/party.ts +++ b/api/src/routes/party.ts @@ -31,6 +31,17 @@ function broadcastToUser(userId: string, event: Record) { void publishDeviceEventForUser(userId, event as PartySocketEvent); } +function broadcastStatusToMembers(snapshot: PartySnapshot | null) { + if (!snapshot) return; + for (const member of snapshot.members) { + broadcastToUser(member.userId, { + type: "party_status", + party: snapshot.party, + members: snapshot.members, + }); + } +} + function isValidStatus( status: string, ): status is import("../party-types").PartyStatus { @@ -65,88 +76,80 @@ export const partyApp = new Elysia() return { error: "Target user not found." }; } - const { partyId, hostChanged, leaveResult } = await db.transaction( - async (tx) => { - const leaveResult = await leaveParty(tx, user.id); - let partyId: string | null = null; - let hostChanged = false; + const { partyId, leaveResult } = await db.transaction(async (tx) => { + const leaveResult = await leaveParty(tx, user.id, { + createReplacementParty: false, + }); + let partyId: string | null = null; - const targetMembership = await getMemberRecord(tx, targetUserId); - if (targetMembership) { - partyId = targetMembership.partyId; - await tx - .update(party) - .set({ - hostId: targetUserId, - lastUpdated: new Date(), - }) - .where(eq(party.id, partyId)); - hostChanged = true; - } else { - const created = await tx - .insert(party) - .values({ - status: "created", - hostId: targetUserId, - }) - .returning({ id: party.id }); - const createdId = created[0]?.id ?? null; - if (!createdId) { - return { - partyId: null, - hostChanged, - leaveResult, - }; - } - partyId = createdId; - await tx.insert(partyMember).values({ - partyId, - userId: targetUserId, - }); - } - - if (!partyId) { + const targetMembership = await getMemberRecord(tx, targetUserId); + if (targetMembership) { + partyId = targetMembership.partyId; + await tx + .update(party) + .set({ + hostId: targetUserId, + lastUpdated: new Date(), + }) + .where(eq(party.id, partyId)); + } else { + const created = await tx + .insert(party) + .values({ + status: "created", + hostId: targetUserId, + }) + .returning({ id: party.id }); + const createdId = created[0]?.id ?? null; + if (!createdId) { return { partyId: null, - hostChanged, leaveResult, }; } - - await tx - .insert(partyMember) - .values({ partyId, userId: user.id }) - .onConflictDoNothing(); - - return { + partyId = createdId; + await tx.insert(partyMember).values({ partyId, - hostChanged, + userId: targetUserId, + }); + } + + if (!partyId) { + return { + partyId: null, leaveResult, }; - }, - ); + } + + await tx + .insert(partyMember) + .values({ partyId, userId: user.id }) + .onConflictDoNothing(); + + return { + partyId, + leaveResult, + }; + }); if (!partyId) return { party: null, members: [] }; + const leaveStatuses = await Promise.all( + (leaveResult?.affectedPartyIds ?? []).map( + async (affectedPartyId) => ({ + partyId: affectedPartyId, + status: await getPartyStatus(affectedPartyId), + }), + ), + ); + for (const { partyId: affectedPartyId, status } of leaveStatuses) { + if (status) { + broadcastSnapshot(affectedPartyId, status); + broadcastStatusToMembers(status); + } + } const status = await getPartyStatus(partyId); - if (leaveResult?.newHostId) { - broadcastSnapshot(leaveResult.partyId, status); - } - if (hostChanged) { - broadcastSnapshot(partyId, status); - } broadcastSnapshot(partyId, status); - if (status) { - broadcastToUser(targetUserId, { - type: "party_status", - party: status.party, - members: status.members, - }); - broadcastToUser(user.id, { - type: "party_status", - party: status.party, - members: status.members, - }); - } + broadcastStatusToMembers(status); return status ?? { party: null, members: [] }; }, { @@ -160,11 +163,30 @@ export const partyApp = new Elysia() "/leave", async ({ user }) => { const result = await db.transaction(async (tx) => { - return await leaveParty(tx, user.id); + return await leaveParty(tx, user.id, { + createReplacementParty: true, + }); }); if (!result) return { party: null, members: [] }; - const status = await getPartyStatus(result.partyId); - broadcastSnapshot(result.partyId, status); + const leaveStatuses = await Promise.all( + result.affectedPartyIds.map(async (affectedPartyId) => ({ + partyId: affectedPartyId, + status: await getPartyStatus(affectedPartyId), + })), + ); + for (const { partyId: affectedPartyId, status } of leaveStatuses) { + if (status) { + broadcastSnapshot(affectedPartyId, status); + broadcastStatusToMembers(status); + } + } + const status = result.replacementPartyId + ? await getPartyStatus(result.replacementPartyId) + : null; + if (result.replacementPartyId) { + broadcastSnapshot(result.replacementPartyId, status); + } + broadcastStatusToMembers(status); return status ?? { party: null, members: [] }; }, { auth: true }, diff --git a/api/src/routes/quiz.ts b/api/src/routes/quiz.ts index 16f96d0..1255ca5 100644 --- a/api/src/routes/quiz.ts +++ b/api/src/routes/quiz.ts @@ -9,6 +9,21 @@ import type { QuizState } from "../party-types"; import { QuizWorkflow, quizQueue } from "../workflows/quiz"; import { pubsub } from "./party-socket"; +function broadcastStatusToMembers( + status: Awaited>, +) { + if (!status) return; + const payload = JSON.stringify({ + type: "party_status", + party: status.party, + members: status.members, + }); + pubsub.publish(`party:${status.party.id}`, payload); + for (const member of status.members) { + pubsub.publish(`user:${member.userId}`, payload); + } +} + const quizWf = new QuizWorkflow(); export const quizRoutes = new Elysia() @@ -45,21 +60,13 @@ export const quizRoutes = new Elysia() .update(party) .set({ status: "started", + data: null, lastUpdated: new Date(), }) .where(eq(party.id, params.partyId)); const status = await getPartyStatus(params.partyId); - if (status) { - pubsub.publish( - `party:${params.partyId}`, - JSON.stringify({ - type: "party_status", - party: status.party, - members: status.members, - }), - ); - } + broadcastStatusToMembers(status); return { message: "Quiz started", diff --git a/web/src/components/user-info.tsx b/web/src/components/user-info.tsx index 51de80d..889f1c8 100644 --- a/web/src/components/user-info.tsx +++ b/web/src/components/user-info.tsx @@ -15,8 +15,14 @@ import { export function UserInfo() { const { user } = useUser(); - const { party, members, isConnecting, isReconnecting, resetParty } = - useParty(); + const { + party, + members, + isConnecting, + isReconnecting, + resetParty, + setPartyState, + } = useParty(); return ( @@ -41,8 +47,12 @@ export function UserInfo() { {party && (