wip party impl

This commit is contained in:
Daniel Bulant 2026-04-20 23:02:54 +02:00
parent dd51cd10f5
commit 7738645a69
No known key found for this signature in database
4 changed files with 366 additions and 4 deletions

View file

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

View file

@ -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;

298
api/src/routes/party.ts Normal file
View file

@ -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<typeof db.transaction>[0] extends (
tx: infer T,
) => Promise<any>
? 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()),
}),
},
),
);

View file

@ -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 (
<MainContent>
<UserInfo />
<QuickStats />
<SyncButton />
</MainContent>
);