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) { 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()), }), }, ), );