itpdp/api/src/routes/party.ts
2026-05-11 20:21:01 +02:00

273 lines
7.1 KiB
TypeScript

import { and, eq } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { betterAuthElysia } from "../auth";
import { db } from "../db";
import { party, partyMember } from "../db/schema";
import {
cleanupPartyIfEmpty,
getMemberRecord,
getPartyForUser,
getPartyStatus,
leaveParty,
} from "../party-data";
import type { PartySnapshot, PartySocketEvent } from "../party-types";
import { publishDeviceEventForUser } from "./device-socket";
import { pubsub, topic } from "./party-socket";
function broadcastSnapshot(partyId: string, snapshot: PartySnapshot | null) {
if (!snapshot) return;
pubsub.publish(
topic.party(partyId),
JSON.stringify({
type: "party_status",
party: snapshot.party,
members: snapshot.members,
}),
);
}
function broadcastToUser(userId: string, event: Record<string, unknown>) {
pubsub.publish(topic.user(userId), JSON.stringify(event));
void publishDeviceEventForUser(userId, event as PartySocketEvent);
}
function isValidStatus(
status: string,
): status is import("../party-types").PartyStatus {
return ["created", "started", "ended"].includes(status);
}
export const partyApp = new Elysia()
.use(betterAuthElysia)
.group("/party", (app) =>
app
.get(
"/status",
async ({ user }) => {
const currentParty = await getPartyForUser(user.id);
if (!currentParty) return { party: null, members: [] };
const status = await getPartyStatus(currentParty.id);
return status ?? { party: null, members: [] };
},
{ auth: true },
)
.post(
"/join",
async ({ user, body, set }) => {
const targetUserId = body.targetUserId;
const targetUser = await db.query.user.findFirst({
where: {
id: targetUserId,
},
});
if (!targetUser) {
set.status = 404;
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 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) {
return {
partyId: null,
hostChanged,
leaveResult,
};
}
await tx
.insert(partyMember)
.values({ partyId, userId: user.id })
.onConflictDoNothing();
return {
partyId,
hostChanged,
leaveResult,
};
},
);
if (!partyId) return { party: null, members: [] };
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,
});
}
return status ?? { party: null, members: [] };
},
{
auth: true,
body: t.Object({
targetUserId: t.String(),
}),
},
)
.post(
"/leave",
async ({ user }) => {
const result = await db.transaction(async (tx) => {
return await leaveParty(tx, user.id);
});
if (!result) return { party: null, members: [] };
const status = await getPartyStatus(result.partyId);
broadcastSnapshot(result.partyId, status);
return status ?? { party: null, members: [] };
},
{ auth: true },
)
.post(
"/kick",
async ({ user, body, set }) => {
const currentMembership = await getMemberRecord(db, user.id);
if (!currentMembership) {
set.status = 400;
return { error: "You are not in a party." };
}
const currentParty = await db.query.party.findFirst({
where: {
id: currentMembership.partyId,
},
});
if (!currentParty || currentParty.hostId !== user.id) {
set.status = 403;
return { error: "Only the host can kick members." };
}
if (body.memberUserId === user.id) {
set.status = 400;
return { error: "Host cannot kick themselves." };
}
await db.transaction(async (tx) => {
await tx
.delete(partyMember)
.where(
and(
eq(partyMember.partyId, currentMembership.partyId),
eq(partyMember.userId, body.memberUserId),
),
);
await cleanupPartyIfEmpty(tx, currentMembership.partyId);
});
const status = await getPartyStatus(currentMembership.partyId);
broadcastSnapshot(currentMembership.partyId, status);
return status ?? { party: null, members: [] };
},
{
auth: true,
body: t.Object({
memberUserId: t.String(),
}),
},
)
.post(
"/status",
async ({ user, body, set }) => {
const currentMembership = await getMemberRecord(db, user.id);
if (!currentMembership) {
set.status = 400;
return { error: "You are not in a party." };
}
const currentParty = await db.query.party.findFirst({
where: {
id: currentMembership.partyId,
},
});
if (!currentParty || currentParty.hostId !== user.id) {
set.status = 403;
return { error: "Only the host can update party status." };
}
if (!isValidStatus(body.status)) {
set.status = 400;
return { error: "Invalid party status." };
}
const currentData =
currentParty?.data && typeof currentParty.data === "object"
? currentParty.data
: {};
const nextData = body.data
? { ...currentData, ...body.data }
: currentData;
await db.transaction(async (tx) => {
await tx
.update(party)
.set({
status: body.status,
data: nextData,
lastUpdated: new Date(),
})
.where(eq(party.id, currentMembership.partyId));
});
const status = await getPartyStatus(currentMembership.partyId);
broadcastSnapshot(currentMembership.partyId, status);
return status ?? { party: null, members: [] };
},
{
auth: true,
body: t.Object({
status: t.Enum({ created: "created", started: "started" }),
data: t.Optional(t.Any()),
}),
},
),
);