This commit is contained in:
Daniel Bulant 2026-05-16 13:15:40 +02:00
parent 09e327e19a
commit bfeb44a625
No known key found for this signature in database
7 changed files with 260 additions and 106 deletions

View file

@ -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<LeavePartyResult | null> {
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,
};
}

View file

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

View file

@ -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

View file

@ -31,6 +31,17 @@ function broadcastToUser(userId: string, event: Record<string, unknown>) {
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 },

View file

@ -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<ReturnType<typeof getPartyStatus>>,
) {
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",

View file

@ -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 (
<Item>
<ItemMedia>
@ -41,8 +47,12 @@ export function UserInfo() {
{party && (
<Button
onClick={async () => {
await client.api.party.leave.post();
resetParty();
const result = await client.api.party.leave.post();
if (result?.party) {
setPartyState(result);
} else {
resetParty();
}
}}
>
Leave

View file

@ -1,8 +1,8 @@
import { treaty } from "@elysiajs/eden";
import type { App } from "../../../api/src/index";
// export const client = treaty<App>("aura.rpi1.danbulant.cloud", {});
export const client = treaty<App>(
process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000",
{},
);
export const client = treaty<App>("aura.rpi1.danbulant.cloud", {});
// export const client = treaty<App>(
// process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000",
// {},
// );