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: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
joinedAt: "desc",
|
||||||
|
},
|
||||||
with: {
|
with: {
|
||||||
party: true,
|
party: true,
|
||||||
},
|
},
|
||||||
|
|
@ -77,23 +80,68 @@ export async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) {
|
||||||
await dbClient.delete(party).where(eq(party.id, partyId));
|
await dbClient.delete(party).where(eq(party.id, partyId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function leaveParty(dbClient: DbLike, userId: string) {
|
export type LeavePartyResult = {
|
||||||
const member = await getMemberRecord(dbClient, userId);
|
affectedPartyIds: string[];
|
||||||
if (!member) return null;
|
replacementPartyId: string | null;
|
||||||
await dbClient.delete(partyMember).where(eq(partyMember.id, member.id));
|
};
|
||||||
const nextHost = await dbClient.query.partyMember.findFirst({
|
|
||||||
|
export async function leaveParty(
|
||||||
|
dbClient: DbLike,
|
||||||
|
userId: string,
|
||||||
|
options: { createReplacementParty?: boolean } = {},
|
||||||
|
): Promise<LeavePartyResult | null> {
|
||||||
|
const memberships = await dbClient.query.partyMember.findMany({
|
||||||
where: {
|
where: {
|
||||||
partyId: member.partyId,
|
userId,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
joinedAt: "asc",
|
joinedAt: "desc",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let newHostId: string | null = null;
|
if (memberships.length === 0) {
|
||||||
if (nextHost) {
|
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({
|
const currentParty = await dbClient.query.party.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: member.partyId,
|
id: partyId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (currentParty?.hostId === userId) {
|
if (currentParty?.hostId === userId) {
|
||||||
|
|
@ -103,13 +151,30 @@ export async function leaveParty(dbClient: DbLike, userId: string) {
|
||||||
hostId: nextHost.userId,
|
hostId: nextHost.userId,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(party.id, member.partyId));
|
.where(eq(party.id, partyId));
|
||||||
newHostId = nextHost.userId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 {
|
return {
|
||||||
partyId: member.partyId,
|
affectedPartyIds,
|
||||||
newHostId,
|
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) {
|
for (const member of members) {
|
||||||
if (!member.userId) continue;
|
if (!member.userId) continue;
|
||||||
|
pubsub.publish(
|
||||||
|
`user:${member.userId}`,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "party_status",
|
||||||
|
party: {
|
||||||
|
...partyObject,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
members,
|
||||||
|
}),
|
||||||
|
);
|
||||||
void publishDeviceEventForUser(member.userId, event);
|
void publishDeviceEventForUser(member.userId, event);
|
||||||
}
|
}
|
||||||
await db
|
await db
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,17 @@ function broadcastToUser(userId: string, event: Record<string, unknown>) {
|
||||||
void publishDeviceEventForUser(userId, event as PartySocketEvent);
|
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(
|
function isValidStatus(
|
||||||
status: string,
|
status: string,
|
||||||
): status is import("../party-types").PartyStatus {
|
): status is import("../party-types").PartyStatus {
|
||||||
|
|
@ -65,88 +76,80 @@ export const partyApp = new Elysia()
|
||||||
return { error: "Target user not found." };
|
return { error: "Target user not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { partyId, hostChanged, leaveResult } = await db.transaction(
|
const { partyId, leaveResult } = await db.transaction(async (tx) => {
|
||||||
async (tx) => {
|
const leaveResult = await leaveParty(tx, user.id, {
|
||||||
const leaveResult = await leaveParty(tx, user.id);
|
createReplacementParty: false,
|
||||||
let partyId: string | null = null;
|
});
|
||||||
let hostChanged = false;
|
let partyId: string | null = null;
|
||||||
|
|
||||||
const targetMembership = await getMemberRecord(tx, targetUserId);
|
const targetMembership = await getMemberRecord(tx, targetUserId);
|
||||||
if (targetMembership) {
|
if (targetMembership) {
|
||||||
partyId = targetMembership.partyId;
|
partyId = targetMembership.partyId;
|
||||||
await tx
|
await tx
|
||||||
.update(party)
|
.update(party)
|
||||||
.set({
|
.set({
|
||||||
hostId: targetUserId,
|
hostId: targetUserId,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(party.id, partyId));
|
.where(eq(party.id, partyId));
|
||||||
hostChanged = true;
|
} else {
|
||||||
} else {
|
const created = await tx
|
||||||
const created = await tx
|
.insert(party)
|
||||||
.insert(party)
|
.values({
|
||||||
.values({
|
status: "created",
|
||||||
status: "created",
|
hostId: targetUserId,
|
||||||
hostId: targetUserId,
|
})
|
||||||
})
|
.returning({ id: party.id });
|
||||||
.returning({ id: party.id });
|
const createdId = created[0]?.id ?? null;
|
||||||
const createdId = created[0]?.id ?? null;
|
if (!createdId) {
|
||||||
if (!createdId) {
|
|
||||||
return {
|
|
||||||
partyId: null,
|
|
||||||
hostChanged,
|
|
||||||
leaveResult,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
partyId = createdId;
|
|
||||||
await tx.insert(partyMember).values({
|
|
||||||
partyId,
|
|
||||||
userId: targetUserId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!partyId) {
|
|
||||||
return {
|
return {
|
||||||
partyId: null,
|
partyId: null,
|
||||||
hostChanged,
|
|
||||||
leaveResult,
|
leaveResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
partyId = createdId;
|
||||||
await tx
|
await tx.insert(partyMember).values({
|
||||||
.insert(partyMember)
|
|
||||||
.values({ partyId, userId: user.id })
|
|
||||||
.onConflictDoNothing();
|
|
||||||
|
|
||||||
return {
|
|
||||||
partyId,
|
partyId,
|
||||||
hostChanged,
|
userId: targetUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partyId) {
|
||||||
|
return {
|
||||||
|
partyId: null,
|
||||||
leaveResult,
|
leaveResult,
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
await tx
|
||||||
|
.insert(partyMember)
|
||||||
|
.values({ partyId, userId: user.id })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
return {
|
||||||
|
partyId,
|
||||||
|
leaveResult,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (!partyId) return { party: null, members: [] };
|
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);
|
const status = await getPartyStatus(partyId);
|
||||||
if (leaveResult?.newHostId) {
|
|
||||||
broadcastSnapshot(leaveResult.partyId, status);
|
|
||||||
}
|
|
||||||
if (hostChanged) {
|
|
||||||
broadcastSnapshot(partyId, status);
|
|
||||||
}
|
|
||||||
broadcastSnapshot(partyId, status);
|
broadcastSnapshot(partyId, status);
|
||||||
if (status) {
|
broadcastStatusToMembers(status);
|
||||||
broadcastToUser(targetUserId, {
|
|
||||||
type: "party_status",
|
|
||||||
party: status.party,
|
|
||||||
members: status.members,
|
|
||||||
});
|
|
||||||
broadcastToUser(user.id, {
|
|
||||||
type: "party_status",
|
|
||||||
party: status.party,
|
|
||||||
members: status.members,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return status ?? { party: null, members: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -160,11 +163,30 @@ export const partyApp = new Elysia()
|
||||||
"/leave",
|
"/leave",
|
||||||
async ({ user }) => {
|
async ({ user }) => {
|
||||||
const result = await db.transaction(async (tx) => {
|
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: [] };
|
if (!result) return { party: null, members: [] };
|
||||||
const status = await getPartyStatus(result.partyId);
|
const leaveStatuses = await Promise.all(
|
||||||
broadcastSnapshot(result.partyId, status);
|
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: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,21 @@ import type { QuizState } from "../party-types";
|
||||||
import { QuizWorkflow, quizQueue } from "../workflows/quiz";
|
import { QuizWorkflow, quizQueue } from "../workflows/quiz";
|
||||||
import { pubsub } from "./party-socket";
|
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();
|
const quizWf = new QuizWorkflow();
|
||||||
|
|
||||||
export const quizRoutes = new Elysia()
|
export const quizRoutes = new Elysia()
|
||||||
|
|
@ -45,21 +60,13 @@ export const quizRoutes = new Elysia()
|
||||||
.update(party)
|
.update(party)
|
||||||
.set({
|
.set({
|
||||||
status: "started",
|
status: "started",
|
||||||
|
data: null,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(party.id, params.partyId));
|
.where(eq(party.id, params.partyId));
|
||||||
|
|
||||||
const status = await getPartyStatus(params.partyId);
|
const status = await getPartyStatus(params.partyId);
|
||||||
if (status) {
|
broadcastStatusToMembers(status);
|
||||||
pubsub.publish(
|
|
||||||
`party:${params.partyId}`,
|
|
||||||
JSON.stringify({
|
|
||||||
type: "party_status",
|
|
||||||
party: status.party,
|
|
||||||
members: status.members,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: "Quiz started",
|
message: "Quiz started",
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,14 @@ import {
|
||||||
|
|
||||||
export function UserInfo() {
|
export function UserInfo() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { party, members, isConnecting, isReconnecting, resetParty } =
|
const {
|
||||||
useParty();
|
party,
|
||||||
|
members,
|
||||||
|
isConnecting,
|
||||||
|
isReconnecting,
|
||||||
|
resetParty,
|
||||||
|
setPartyState,
|
||||||
|
} = useParty();
|
||||||
return (
|
return (
|
||||||
<Item>
|
<Item>
|
||||||
<ItemMedia>
|
<ItemMedia>
|
||||||
|
|
@ -41,8 +47,12 @@ export function UserInfo() {
|
||||||
{party && (
|
{party && (
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await client.api.party.leave.post();
|
const result = await client.api.party.leave.post();
|
||||||
resetParty();
|
if (result?.party) {
|
||||||
|
setPartyState(result);
|
||||||
|
} else {
|
||||||
|
resetParty();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Leave
|
Leave
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { treaty } from "@elysiajs/eden";
|
import { treaty } from "@elysiajs/eden";
|
||||||
import type { App } from "../../../api/src/index";
|
import type { App } from "../../../api/src/index";
|
||||||
|
|
||||||
// export const client = treaty<App>("aura.rpi1.danbulant.cloud", {});
|
export const client = treaty<App>("aura.rpi1.danbulant.cloud", {});
|
||||||
export const client = treaty<App>(
|
// export const client = treaty<App>(
|
||||||
process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000",
|
// process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000",
|
||||||
{},
|
// {},
|
||||||
);
|
// );
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue