273 lines
7.1 KiB
TypeScript
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()),
|
|
}),
|
|
},
|
|
),
|
|
);
|