wip party impl
This commit is contained in:
parent
dd51cd10f5
commit
7738645a69
4 changed files with 366 additions and 4 deletions
|
|
@ -3,6 +3,7 @@ import {
|
||||||
boolean,
|
boolean,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
|
json,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
pgTable,
|
pgTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
|
|
@ -10,11 +11,49 @@ import {
|
||||||
timestamp,
|
timestamp,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
varchar,
|
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { user } from "./auth-schema";
|
import { user } from "./auth-schema";
|
||||||
export * 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 platform = pgEnum("enum_platform", ["spotify", "apple"]);
|
||||||
|
|
||||||
export const artist = pgTable("artist", {
|
export const artist = pgTable("artist", {
|
||||||
|
|
@ -308,6 +347,8 @@ export const relations = defineRelations(
|
||||||
artistImage,
|
artistImage,
|
||||||
followedArtist,
|
followedArtist,
|
||||||
genre,
|
genre,
|
||||||
|
party,
|
||||||
|
partyMember,
|
||||||
platformImage,
|
platformImage,
|
||||||
playbackHistory,
|
playbackHistory,
|
||||||
savedAlbum,
|
savedAlbum,
|
||||||
|
|
@ -377,6 +418,23 @@ export const relations = defineRelations(
|
||||||
to: r.album.id.through(r.albumImage.albumId),
|
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: {
|
artistImage: {
|
||||||
artist: r.one.artist({
|
artist: r.one.artist({
|
||||||
from: r.artistImage.artistId,
|
from: r.artistImage.artistId,
|
||||||
|
|
@ -520,5 +578,12 @@ export const relations = defineRelations(
|
||||||
to: r.user.id,
|
to: r.user.id,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
partyMembers: r.many.partyMember(),
|
||||||
|
hostedParties: r.many.party({
|
||||||
|
from: r.user.id,
|
||||||
|
to: r.party.hostId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||||
import "./workflows/sync";
|
import "./workflows/sync";
|
||||||
import "./dbos.ts";
|
import "./dbos.ts";
|
||||||
import { statsApp } from "./routes/stats.ts";
|
import { statsApp } from "./routes/stats.ts";
|
||||||
|
import { partyApp } from "./routes/party";
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.use(betterAuthElysia)
|
.use(betterAuthElysia)
|
||||||
.group("/api", (app) => app.use(syncApp).use(statsApp))
|
.group("/api", (app) => app.use(syncApp).use(statsApp).use(partyApp))
|
||||||
.listen(4000);
|
.listen(4000);
|
||||||
|
|
||||||
export type App = typeof app;
|
export type App = typeof app;
|
||||||
|
|
|
||||||
298
api/src/routes/party.ts
Normal file
298
api/src/routes/party.ts
Normal 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()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { QuickStats } from "#/components/quick-stats";
|
|
||||||
import { SyncButton } from "#/components/sync-button";
|
import { SyncButton } from "#/components/sync-button";
|
||||||
import { MainContent } from "#/components/ui/main-content";
|
import { MainContent } from "#/components/ui/main-content";
|
||||||
import { UserInfo } from "#/components/user-info";
|
import { UserInfo } from "#/components/user-info";
|
||||||
|
|
@ -10,7 +9,6 @@ function App() {
|
||||||
return (
|
return (
|
||||||
<MainContent>
|
<MainContent>
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
<QuickStats />
|
|
||||||
<SyncButton />
|
<SyncButton />
|
||||||
</MainContent>
|
</MainContent>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue