diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 1806d38..b939b65 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -3,6 +3,7 @@ import { boolean, index, integer, + json, pgEnum, pgTable, primaryKey, @@ -10,11 +11,49 @@ import { timestamp, uniqueIndex, uuid, - varchar, } from "drizzle-orm/pg-core"; import { user } from "./auth-schema"; export * from "./auth-schema"; +export const partyStatus = pgEnum("party_status", [ + "created", + "started", + "ended", +]); + +export const party = pgTable("party", { + id: uuid().defaultRandom().primaryKey().notNull(), + hostId: text() + .references(() => user.id) + .notNull(), + data: json(), + analysisData: json(), + createdAt: timestamp().defaultNow().notNull(), + lastUpdated: timestamp().defaultNow().notNull(), + status: partyStatus().notNull(), +}); + +export const memberStatus = pgEnum("member_status", [ + "connected", + "disconnected", +]); + +export const partyMember = pgTable( + "party_member", + { + id: uuid().defaultRandom().primaryKey().notNull(), + partyId: uuid() + .references(() => party.id) + .notNull(), + userId: uuid() + .references(() => user.id) + .notNull(), + joinedAt: timestamp().defaultNow().notNull(), + lastSeen: timestamp().defaultNow().notNull(), + }, + (partyMember) => [uniqueIndex().on(partyMember.partyId, partyMember.userId)], +); + export const platform = pgEnum("enum_platform", ["spotify", "apple"]); export const artist = pgTable("artist", { @@ -308,6 +347,8 @@ export const relations = defineRelations( artistImage, followedArtist, genre, + party, + partyMember, platformImage, playbackHistory, savedAlbum, @@ -377,6 +418,23 @@ export const relations = defineRelations( to: r.album.id.through(r.albumImage.albumId), }), }, + party: { + members: r.many.partyMember(), + host: r.one.user({ + from: r.party.hostId, + to: r.user.id, + }), + }, + partyMember: { + party: r.one.party({ + from: r.partyMember.partyId, + to: r.party.id, + }), + user: r.one.user({ + from: r.partyMember.userId, + to: r.user.id, + }), + }, artistImage: { artist: r.one.artist({ from: r.artistImage.artistId, @@ -520,5 +578,12 @@ export const relations = defineRelations( to: r.user.id, }), }, + user: { + partyMembers: r.many.partyMember(), + hostedParties: r.many.party({ + from: r.user.id, + to: r.party.hostId, + }), + }, }), ); diff --git a/api/src/index.ts b/api/src/index.ts index e07dc4a..7b170b9 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -5,10 +5,11 @@ import { DBOS } from "@dbos-inc/dbos-sdk"; import "./workflows/sync"; import "./dbos.ts"; import { statsApp } from "./routes/stats.ts"; +import { partyApp } from "./routes/party"; const app = new Elysia() .use(betterAuthElysia) - .group("/api", (app) => app.use(syncApp).use(statsApp)) + .group("/api", (app) => app.use(syncApp).use(statsApp).use(partyApp)) .listen(4000); export type App = typeof app; diff --git a/api/src/routes/party.ts b/api/src/routes/party.ts new file mode 100644 index 0000000..b34e68b --- /dev/null +++ b/api/src/routes/party.ts @@ -0,0 +1,298 @@ +import Elysia, { t } from "elysia"; +import { and, eq } from "drizzle-orm"; +import { betterAuthElysia } from "../auth"; +import { db } from "../db"; +import { party, partyMember } from "../db/schema"; + +const PARTY_STATUS = ["created", "started", "ended"] as const; + +type PartyStatus = (typeof PARTY_STATUS)[number]; + +type DbClient = typeof db; +type DbTransaction = Parameters[0] extends ( + tx: infer T, +) => Promise + ? T + : never; +type DbLike = DbClient | DbTransaction; + +async function getPartyForUser(userId: string) { + const memberships = await db.query.partyMember.findMany({ + where: { + userId, + }, + with: { + party: true, + }, + limit: 1, + }); + return memberships[0]?.party ?? null; +} + +async function getMemberRecord(dbClient: DbLike, userId: string) { + return ( + (await dbClient.query.partyMember.findFirst({ + where: { + userId, + }, + })) ?? null + ); +} + +async function getPartyStatus(partyId: string) { + const party = await db.query.party.findFirst({ + where: { + id: partyId, + }, + }); + if (!party) return null; + const members = await db.query.partyMember.findMany({ + where: { + partyId, + }, + with: { + user: true, + }, + orderBy: { + joinedAt: "asc", + }, + }); + return { + party, + members, + }; +} + +async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) { + const members = await dbClient.query.partyMember.findMany({ + where: { + partyId, + }, + limit: 1, + }); + if (members.length > 0) return; + await dbClient.delete(party).where(eq(party.id, partyId)); +} + +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({ + where: { + partyId: member.partyId, + }, + orderBy: { + joinedAt: "asc", + }, + }); + if (nextHost) { + const currentParty = await dbClient.query.party.findFirst({ + where: { + id: member.partyId, + }, + }); + if (currentParty?.hostId === userId) { + await dbClient + .update(party) + .set({ + hostId: nextHost.userId, + lastUpdated: new Date(), + }) + .where(eq(party.id, member.partyId)); + } + } + await cleanupPartyIfEmpty(dbClient, member.partyId); + return member.partyId; +} + +function isValidStatus(status: string): status is PartyStatus { + return PARTY_STATUS.includes(status as PartyStatus); +} + +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." }; + } + + let partyId: string | null = null; + await db.transaction(async (tx) => { + await leaveParty(tx, user.id); + + 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 }); + partyId = created[0]!.id; + await tx.insert(partyMember).values({ + partyId, + userId: targetUserId, + }); + } + + await tx + .insert(partyMember) + .values({ partyId, userId: user.id }) + .onConflictDoNothing(); + }); + + if (!partyId) return { party: null, members: [] }; + const status = await getPartyStatus(partyId); + return status ?? { party: null, members: [] }; + }, + { + auth: true, + body: t.Object({ + targetUserId: t.String(), + }), + }, + ) + .post( + "/leave", + async ({ user }) => { + const partyId = await db.transaction(async (tx) => { + return await leaveParty(tx, user.id); + }); + if (!partyId) return { party: null, members: [] }; + const status = await getPartyStatus(partyId); + 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); + 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); + return status ?? { party: null, members: [] }; + }, + { + auth: true, + body: t.Object({ + status: t.Enum({ created: "created", started: "started" }), + data: t.Optional(t.Any()), + }), + }, + ), + ); diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index c598138..233b0f1 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -1,4 +1,3 @@ -import { QuickStats } from "#/components/quick-stats"; import { SyncButton } from "#/components/sync-button"; import { MainContent } from "#/components/ui/main-content"; import { UserInfo } from "#/components/user-info"; @@ -10,7 +9,6 @@ function App() { return ( - );