update
This commit is contained in:
parent
09e327e19a
commit
bfeb44a625
7 changed files with 260 additions and 106 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
39
api/src/party/__tests__/party-data.test.ts
Normal file
39
api/src/party/__tests__/party-data.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
// {},
|
||||
// );
|
||||
|
|
|
|||
Loading…
Reference in a new issue