format issues + socket
This commit is contained in:
parent
7738645a69
commit
f79a9893a1
16 changed files with 1984 additions and 1654 deletions
6
api/AGENTS.md
Normal file
6
api/AGENTS.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
Run biome and typescript checks after your changes:
|
||||||
|
|
||||||
|
```
|
||||||
|
bun x biome ci
|
||||||
|
bun x tsc --noEmit
|
||||||
|
```
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error("Missing required env var: DATABASE_URL");
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
schema: "./src/db/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL!,
|
url: databaseUrl,
|
||||||
},
|
},
|
||||||
schemaFilter: ["public"],
|
schemaFilter: ["public"],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.ts"
|
"dev": "bun run --watch src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
|
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import Elysia from "elysia";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import Elysia, { status, type Context } from "elysia";
|
|
||||||
import * as schema from "./db/auth-schema";
|
import * as schema from "./db/auth-schema";
|
||||||
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
|
||||||
|
|
||||||
export const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID!;
|
const requireEnv = (name: string): string => {
|
||||||
export const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
const value = process.env[name];
|
||||||
|
if (!value) throw new Error(`Missing required env var: ${name}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SPOTIFY_CLIENT_ID = requireEnv("SPOTIFY_CLIENT_ID");
|
||||||
|
export const SPOTIFY_CLIENT_SECRET = requireEnv("SPOTIFY_CLIENT_SECRET");
|
||||||
|
|
||||||
export const defaultSdk = SpotifyApi.withClientCredentials(
|
export const defaultSdk = SpotifyApi.withClientCredentials(
|
||||||
SPOTIFY_CLIENT_ID,
|
SPOTIFY_CLIENT_ID,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { relations } from "drizzle-orm/_relations";
|
import { relations } from "drizzle-orm/_relations";
|
||||||
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
|
import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const user = pgTable("user", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import { relations } from "./schema";
|
import { relations } from "./schema";
|
||||||
|
|
||||||
export const db = drizzle(process.env.DATABASE_URL!, { relations });
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error("Missing required env var: DATABASE_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = drizzle(databaseUrl, { relations });
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
uuid,
|
uuid,
|
||||||
} 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", [
|
export const partyStatus = pgEnum("party_status", [
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import type {
|
||||||
SimplifiedArtist,
|
SimplifiedArtist,
|
||||||
Track,
|
Track,
|
||||||
} from "@spotify/web-api-ts-sdk";
|
} from "@spotify/web-api-ts-sdk";
|
||||||
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import { defaultSdk } from "../auth";
|
||||||
import { db } from ".";
|
import { db } from ".";
|
||||||
import {
|
import {
|
||||||
album,
|
album,
|
||||||
|
|
@ -20,8 +22,8 @@ import {
|
||||||
artistImage,
|
artistImage,
|
||||||
followedArtist,
|
followedArtist,
|
||||||
genre,
|
genre,
|
||||||
playbackHistory,
|
|
||||||
platformImage,
|
platformImage,
|
||||||
|
playbackHistory,
|
||||||
savedAlbum,
|
savedAlbum,
|
||||||
savedTrack,
|
savedTrack,
|
||||||
topArtist,
|
topArtist,
|
||||||
|
|
@ -29,19 +31,23 @@ import {
|
||||||
track,
|
track,
|
||||||
trackArtist,
|
trackArtist,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
||||||
import { defaultSdk } from "../auth";
|
|
||||||
|
|
||||||
export const PLATFORM_SPOTIFY = "spotify" as const;
|
export const PLATFORM_SPOTIFY = "spotify" as const;
|
||||||
|
|
||||||
type DbClient = typeof db;
|
type DbClient = typeof db;
|
||||||
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
||||||
tx: infer T,
|
tx: infer T,
|
||||||
) => Promise<any>
|
) => Promise<unknown>
|
||||||
? T
|
? T
|
||||||
: never;
|
: never;
|
||||||
type DbLike = DbClient | DbTransaction;
|
type DbLike = DbClient | DbTransaction;
|
||||||
|
|
||||||
|
const requireMapEntry = <K, V>(map: Map<K, V>, key: K, label: string): V => {
|
||||||
|
const value = map.get(key);
|
||||||
|
if (!value) throw new Error(`Missing ${label} for ${String(key)}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
export async function upsertImages(images: Image[], dbClient: DbLike = db) {
|
export async function upsertImages(images: Image[], dbClient: DbLike = db) {
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(platformImage)
|
.insert(platformImage)
|
||||||
|
|
@ -66,7 +72,7 @@ export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) {
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(artist)
|
.insert(artist)
|
||||||
.values(
|
.values(
|
||||||
artists.map(({ id, name, images, genres, popularity, type }) => ({
|
artists.map(({ id, name, popularity, type }) => ({
|
||||||
platform: PLATFORM_SPOTIFY,
|
platform: PLATFORM_SPOTIFY,
|
||||||
platform_id: id,
|
platform_id: id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -135,7 +141,7 @@ async function lookupMissingArtists(
|
||||||
) {
|
) {
|
||||||
const missingArtistIds = await getMissingArtists(artistIds, dbClient);
|
const missingArtistIds = await getMissingArtists(artistIds, dbClient);
|
||||||
if (missingArtistIds.length === 0) return [];
|
if (missingArtistIds.length === 0) return [];
|
||||||
let missingArtists: Artist[] = [];
|
const missingArtists: Artist[] = [];
|
||||||
for (let i = 0; i < missingArtistIds.length / 50; i++) {
|
for (let i = 0; i < missingArtistIds.length / 50; i++) {
|
||||||
missingArtists.push(
|
missingArtists.push(
|
||||||
...(await defaultSdk.artists.get(
|
...(await defaultSdk.artists.get(
|
||||||
|
|
@ -150,7 +156,9 @@ async function lookupMissingArtists(
|
||||||
function isFullArtistArray(
|
function isFullArtistArray(
|
||||||
artists: Artist[] | SimplifiedArtist[],
|
artists: Artist[] | SimplifiedArtist[],
|
||||||
): artists is Artist[] {
|
): artists is Artist[] {
|
||||||
return "images" in artists[0]!;
|
const firstArtist = artists[0];
|
||||||
|
if (!firstArtist) return false;
|
||||||
|
return "images" in firstArtist;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertMissingArtists(
|
async function upsertMissingArtists(
|
||||||
|
|
@ -192,7 +200,11 @@ async function getAlbumIdMap(albumIds: string[], dbClient: DbLike = db) {
|
||||||
.select({ id: album.id, platform_id: album.platform_id })
|
.select({ id: album.id, platform_id: album.platform_id })
|
||||||
.from(album)
|
.from(album)
|
||||||
.where(inArray(album.platform_id, albumIds));
|
.where(inArray(album.platform_id, albumIds));
|
||||||
return new Map(rows.map((row) => [row.platform_id ?? "", row.id]));
|
return new Map(
|
||||||
|
rows
|
||||||
|
.filter((row) => row.platform_id)
|
||||||
|
.map((row) => [row.platform_id as string, row.id]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) {
|
async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) {
|
||||||
|
|
@ -201,7 +213,11 @@ async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) {
|
||||||
.select({ id: track.id, platform_id: track.platform_id })
|
.select({ id: track.id, platform_id: track.platform_id })
|
||||||
.from(track)
|
.from(track)
|
||||||
.where(inArray(track.platform_id, trackIds));
|
.where(inArray(track.platform_id, trackIds));
|
||||||
return new Map(rows.map((row) => [row.platform_id ?? "", row.id]));
|
return new Map(
|
||||||
|
rows
|
||||||
|
.filter((row) => row.platform_id)
|
||||||
|
.map((row) => [row.platform_id as string, row.id]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertAlbums(
|
export async function upsertAlbums(
|
||||||
|
|
@ -309,7 +325,7 @@ export async function upsertTracks(tracks: Track[], dbClient: DbLike = db) {
|
||||||
.insert(track)
|
.insert(track)
|
||||||
.values(
|
.values(
|
||||||
tracks.map((spotifyTrack) => ({
|
tracks.map((spotifyTrack) => ({
|
||||||
albumId: albumIdMap.get(spotifyTrack.album.id)!,
|
albumId: requireMapEntry(albumIdMap, spotifyTrack.album.id, "albumId"),
|
||||||
name: spotifyTrack.name,
|
name: spotifyTrack.name,
|
||||||
platform: PLATFORM_SPOTIFY,
|
platform: PLATFORM_SPOTIFY,
|
||||||
platform_id: spotifyTrack.id,
|
platform_id: spotifyTrack.id,
|
||||||
|
|
@ -359,7 +375,7 @@ export async function upsertTopArtists(
|
||||||
.insert(topArtist)
|
.insert(topArtist)
|
||||||
.values(
|
.values(
|
||||||
artists.map((spotifyArtist, index) => ({
|
artists.map((spotifyArtist, index) => ({
|
||||||
artistId: artistIdMap.get(spotifyArtist.id)!,
|
artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"),
|
||||||
position: index + 1,
|
position: index + 1,
|
||||||
userId,
|
userId,
|
||||||
timeline,
|
timeline,
|
||||||
|
|
@ -384,7 +400,7 @@ export async function upsertTopTracks(
|
||||||
.insert(topTrack)
|
.insert(topTrack)
|
||||||
.values(
|
.values(
|
||||||
tracks.map((spotifyTrack, index) => ({
|
tracks.map((spotifyTrack, index) => ({
|
||||||
trackId: trackIdMap.get(spotifyTrack.id)!,
|
trackId: requireMapEntry(trackIdMap, spotifyTrack.id, "trackId"),
|
||||||
position: index + 1,
|
position: index + 1,
|
||||||
userId,
|
userId,
|
||||||
timeline,
|
timeline,
|
||||||
|
|
@ -409,7 +425,7 @@ export async function upsertSavedAlbums(
|
||||||
.insert(savedAlbum)
|
.insert(savedAlbum)
|
||||||
.values(
|
.values(
|
||||||
saved.map((item) => ({
|
saved.map((item) => ({
|
||||||
albumId: albumIdMap.get(item.album.id)!,
|
albumId: requireMapEntry(albumIdMap, item.album.id, "albumId"),
|
||||||
userId,
|
userId,
|
||||||
saved_at: new Date(item.added_at),
|
saved_at: new Date(item.added_at),
|
||||||
})),
|
})),
|
||||||
|
|
@ -433,7 +449,7 @@ export async function upsertSavedTracks(
|
||||||
.insert(savedTrack)
|
.insert(savedTrack)
|
||||||
.values(
|
.values(
|
||||||
saved.map((item) => ({
|
saved.map((item) => ({
|
||||||
trackId: trackIdMap.get(item.track.id)!,
|
trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"),
|
||||||
userId,
|
userId,
|
||||||
saved_at: new Date(item.added_at),
|
saved_at: new Date(item.added_at),
|
||||||
})),
|
})),
|
||||||
|
|
@ -456,7 +472,7 @@ export async function upsertFollowedArtists(
|
||||||
.insert(followedArtist)
|
.insert(followedArtist)
|
||||||
.values(
|
.values(
|
||||||
artists.map((spotifyArtist) => ({
|
artists.map((spotifyArtist) => ({
|
||||||
artistId: artistIdMap.get(spotifyArtist.id)!,
|
artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"),
|
||||||
userId,
|
userId,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
@ -479,7 +495,7 @@ export async function upsertPlaybackHistory(
|
||||||
.insert(playbackHistory)
|
.insert(playbackHistory)
|
||||||
.values(
|
.values(
|
||||||
items.map((item) => ({
|
items.map((item) => ({
|
||||||
trackId: trackIdMap.get(item.track.id)!,
|
trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"),
|
||||||
userId,
|
userId,
|
||||||
played_at: new Date(item.played_at),
|
played_at: new Date(item.played_at),
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||||
|
import { Elysia } from "elysia";
|
||||||
import { betterAuthElysia } from "./auth";
|
import { betterAuthElysia } from "./auth";
|
||||||
import { syncApp } from "./routes/sync";
|
import { syncApp } from "./routes/sync";
|
||||||
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 { partyApp } from "./routes/party";
|
import { partyApp } from "./routes/party";
|
||||||
|
import { statsApp } from "./routes/stats.ts";
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.use(betterAuthElysia)
|
.use(betterAuthElysia)
|
||||||
|
|
|
||||||
85
api/src/party-sockets.ts
Normal file
85
api/src/party-sockets.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
type PartySocketEvent = {
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebSocketLike = {
|
||||||
|
send: (data: string) => void;
|
||||||
|
close?: (code?: number, reason?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const partySockets = new Map<string, Map<string, Set<WebSocketLike>>>();
|
||||||
|
|
||||||
|
function getPartyUserSockets(partyId: string, userId: string) {
|
||||||
|
const partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) return null;
|
||||||
|
return partyMap.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPartySocket(
|
||||||
|
partyId: string,
|
||||||
|
userId: string,
|
||||||
|
ws: WebSocketLike,
|
||||||
|
) {
|
||||||
|
let partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) {
|
||||||
|
partyMap = new Map();
|
||||||
|
partySockets.set(partyId, partyMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userSockets = partyMap.get(userId);
|
||||||
|
if (!userSockets) {
|
||||||
|
userSockets = new Set();
|
||||||
|
partyMap.set(userId, userSockets);
|
||||||
|
}
|
||||||
|
|
||||||
|
userSockets.add(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterPartySocket(
|
||||||
|
partyId: string,
|
||||||
|
userId: string,
|
||||||
|
ws: WebSocketLike,
|
||||||
|
) {
|
||||||
|
const partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) return;
|
||||||
|
|
||||||
|
const userSockets = partyMap.get(userId);
|
||||||
|
if (!userSockets) return;
|
||||||
|
|
||||||
|
userSockets.delete(ws);
|
||||||
|
|
||||||
|
if (userSockets.size === 0) {
|
||||||
|
partyMap.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partyMap.size === 0) {
|
||||||
|
partySockets.delete(partyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastPartyEvent(partyId: string, event: PartySocketEvent) {
|
||||||
|
const partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(event);
|
||||||
|
for (const userSockets of partyMap.values()) {
|
||||||
|
for (const ws of userSockets) {
|
||||||
|
ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPartyEventToUser(
|
||||||
|
partyId: string,
|
||||||
|
userId: string,
|
||||||
|
event: PartySocketEvent,
|
||||||
|
) {
|
||||||
|
const userSockets = getPartyUserSockets(partyId, userId);
|
||||||
|
if (!userSockets) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(event);
|
||||||
|
for (const ws of userSockets) {
|
||||||
|
ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import Elysia, { t } from "elysia";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { betterAuthElysia } from "../auth";
|
import Elysia, { t } from "elysia";
|
||||||
|
import { auth, betterAuthElysia } from "../auth";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { party, partyMember } from "../db/schema";
|
import { party, partyMember } from "../db/schema";
|
||||||
|
import {
|
||||||
|
broadcastPartyEvent,
|
||||||
|
registerPartySocket,
|
||||||
|
sendPartyEventToUser,
|
||||||
|
unregisterPartySocket,
|
||||||
|
} from "../party-sockets";
|
||||||
|
|
||||||
const PARTY_STATUS = ["created", "started", "ended"] as const;
|
const PARTY_STATUS = ["created", "started", "ended"] as const;
|
||||||
|
|
||||||
|
|
@ -11,11 +17,29 @@ type PartyStatus = (typeof PARTY_STATUS)[number];
|
||||||
type DbClient = typeof db;
|
type DbClient = typeof db;
|
||||||
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
||||||
tx: infer T,
|
tx: infer T,
|
||||||
) => Promise<any>
|
) => Promise<unknown>
|
||||||
? T
|
? T
|
||||||
: never;
|
: never;
|
||||||
type DbLike = DbClient | DbTransaction;
|
type DbLike = DbClient | DbTransaction;
|
||||||
|
|
||||||
|
type PartySnapshot = NonNullable<Awaited<ReturnType<typeof getPartyStatus>>>;
|
||||||
|
|
||||||
|
type PartySocketMessage =
|
||||||
|
| {
|
||||||
|
type: "member_payload";
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ping";
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_MEMBER_PAYLOAD_SIZE = 8_000;
|
||||||
|
|
||||||
|
type PartyWsData = {
|
||||||
|
user?: { id: string };
|
||||||
|
partyId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
async function getPartyForUser(userId: string) {
|
async function getPartyForUser(userId: string) {
|
||||||
const memberships = await db.query.partyMember.findMany({
|
const memberships = await db.query.partyMember.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -63,6 +87,23 @@ async function getPartyStatus(partyId: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function broadcastSnapshot(partyId: string, snapshot: PartySnapshot | null) {
|
||||||
|
if (!snapshot) return;
|
||||||
|
broadcastPartyEvent(partyId, {
|
||||||
|
type: "party_status",
|
||||||
|
party: snapshot.party,
|
||||||
|
members: snapshot.members,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadSize(payload: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload).length;
|
||||||
|
} catch {
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) {
|
async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) {
|
||||||
const members = await dbClient.query.partyMember.findMany({
|
const members = await dbClient.query.partyMember.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -86,6 +127,7 @@ async function leaveParty(dbClient: DbLike, userId: string) {
|
||||||
joinedAt: "asc",
|
joinedAt: "asc",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
let newHostId: string | null = null;
|
||||||
if (nextHost) {
|
if (nextHost) {
|
||||||
const currentParty = await dbClient.query.party.findFirst({
|
const currentParty = await dbClient.query.party.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -100,10 +142,14 @@ async function leaveParty(dbClient: DbLike, userId: string) {
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(party.id, member.partyId));
|
.where(eq(party.id, member.partyId));
|
||||||
|
newHostId = nextHost.userId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await cleanupPartyIfEmpty(dbClient, member.partyId);
|
await cleanupPartyIfEmpty(dbClient, member.partyId);
|
||||||
return member.partyId;
|
return {
|
||||||
|
partyId: member.partyId,
|
||||||
|
newHostId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidStatus(status: string): status is PartyStatus {
|
function isValidStatus(status: string): status is PartyStatus {
|
||||||
|
|
@ -114,6 +160,101 @@ export const partyApp = new Elysia()
|
||||||
.use(betterAuthElysia)
|
.use(betterAuthElysia)
|
||||||
.group("/party", (app) =>
|
.group("/party", (app) =>
|
||||||
app
|
app
|
||||||
|
.ws("/ws", {
|
||||||
|
beforeHandle: async ({ request, set }) => {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: request.headers,
|
||||||
|
});
|
||||||
|
if (!session) {
|
||||||
|
set.status = 401;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: session.user,
|
||||||
|
session: session.session,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
open: async (ws) => {
|
||||||
|
const data = ws.data as unknown as PartyWsData;
|
||||||
|
const user = data.user;
|
||||||
|
if (!user) return;
|
||||||
|
const membership = await getMemberRecord(db, user.id);
|
||||||
|
if (!membership) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "You are not in a party.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ws.close?.(1008, "Not in a party");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await getPartyStatus(membership.partyId);
|
||||||
|
data.partyId = membership.partyId;
|
||||||
|
registerPartySocket(membership.partyId, user.id, ws);
|
||||||
|
if (snapshot) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "snapshot",
|
||||||
|
party: snapshot.party,
|
||||||
|
members: snapshot.members,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: async (ws, message: PartySocketMessage) => {
|
||||||
|
const data = ws.data as unknown as PartyWsData;
|
||||||
|
const user = data.user;
|
||||||
|
if (!user) return;
|
||||||
|
if (message.type === "ping") {
|
||||||
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type !== "member_payload") return;
|
||||||
|
const membership = await getMemberRecord(db, user.id);
|
||||||
|
if (!membership) return;
|
||||||
|
|
||||||
|
if (getPayloadSize(message.payload) > MAX_MEMBER_PAYLOAD_SIZE) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Payload too large.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentParty = await db.query.party.findFirst({
|
||||||
|
where: { id: membership.partyId },
|
||||||
|
});
|
||||||
|
if (!currentParty) return;
|
||||||
|
|
||||||
|
sendPartyEventToUser(membership.partyId, currentParty.hostId, {
|
||||||
|
type: "member_payload",
|
||||||
|
fromUserId: user.id,
|
||||||
|
payload: message.payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
close: async (ws) => {
|
||||||
|
const data = ws.data as unknown as PartyWsData;
|
||||||
|
const user = data.user;
|
||||||
|
const { partyId } = data;
|
||||||
|
if (!user) return;
|
||||||
|
if (!partyId) {
|
||||||
|
const membership = await getMemberRecord(db, user.id);
|
||||||
|
if (!membership) return;
|
||||||
|
unregisterPartySocket(membership.partyId, user.id, ws);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unregisterPartySocket(partyId, user.id, ws);
|
||||||
|
},
|
||||||
|
body: t.Union([
|
||||||
|
t.Object({ type: t.Literal("ping") }),
|
||||||
|
t.Object({ type: t.Literal("member_payload"), payload: t.Any() }),
|
||||||
|
]),
|
||||||
|
})
|
||||||
.get(
|
.get(
|
||||||
"/status",
|
"/status",
|
||||||
async ({ user }) => {
|
async ({ user }) => {
|
||||||
|
|
@ -138,9 +279,11 @@ export const partyApp = new Elysia()
|
||||||
return { error: "Target user not found." };
|
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 partyId: string | null = null;
|
||||||
await db.transaction(async (tx) => {
|
let hostChanged = false;
|
||||||
await leaveParty(tx, user.id);
|
|
||||||
|
|
||||||
const targetMembership = await getMemberRecord(tx, targetUserId);
|
const targetMembership = await getMemberRecord(tx, targetUserId);
|
||||||
if (targetMembership) {
|
if (targetMembership) {
|
||||||
|
|
@ -152,6 +295,7 @@ export const partyApp = new Elysia()
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(party.id, partyId));
|
.where(eq(party.id, partyId));
|
||||||
|
hostChanged = true;
|
||||||
} else {
|
} else {
|
||||||
const created = await tx
|
const created = await tx
|
||||||
.insert(party)
|
.insert(party)
|
||||||
|
|
@ -160,21 +304,61 @@ export const partyApp = new Elysia()
|
||||||
hostId: targetUserId,
|
hostId: targetUserId,
|
||||||
})
|
})
|
||||||
.returning({ id: party.id });
|
.returning({ id: party.id });
|
||||||
partyId = created[0]!.id;
|
const createdId = created[0]?.id ?? null;
|
||||||
|
if (!createdId) {
|
||||||
|
return {
|
||||||
|
partyId: null,
|
||||||
|
hostChanged,
|
||||||
|
leaveResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
partyId = createdId;
|
||||||
await tx.insert(partyMember).values({
|
await tx.insert(partyMember).values({
|
||||||
partyId,
|
partyId,
|
||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!partyId) {
|
||||||
|
return {
|
||||||
|
partyId: null,
|
||||||
|
hostChanged,
|
||||||
|
leaveResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await tx
|
await tx
|
||||||
.insert(partyMember)
|
.insert(partyMember)
|
||||||
.values({ partyId, userId: user.id })
|
.values({ partyId, userId: user.id })
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
});
|
|
||||||
|
return {
|
||||||
|
partyId,
|
||||||
|
hostChanged,
|
||||||
|
leaveResult,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!partyId) return { party: null, members: [] };
|
if (!partyId) return { party: null, members: [] };
|
||||||
const status = await getPartyStatus(partyId);
|
const status = await getPartyStatus(partyId);
|
||||||
|
if (leaveResult?.newHostId) {
|
||||||
|
broadcastPartyEvent(leaveResult.partyId, {
|
||||||
|
type: "host_changed",
|
||||||
|
hostId: leaveResult.newHostId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hostChanged) {
|
||||||
|
broadcastPartyEvent(partyId, {
|
||||||
|
type: "host_changed",
|
||||||
|
hostId: targetUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
broadcastPartyEvent(partyId, {
|
||||||
|
type: "member_joined",
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
broadcastSnapshot(partyId, status);
|
||||||
return status ?? { party: null, members: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -187,11 +371,22 @@ export const partyApp = new Elysia()
|
||||||
.post(
|
.post(
|
||||||
"/leave",
|
"/leave",
|
||||||
async ({ user }) => {
|
async ({ user }) => {
|
||||||
const partyId = await db.transaction(async (tx) => {
|
const result = await db.transaction(async (tx) => {
|
||||||
return await leaveParty(tx, user.id);
|
return await leaveParty(tx, user.id);
|
||||||
});
|
});
|
||||||
if (!partyId) return { party: null, members: [] };
|
if (!result) return { party: null, members: [] };
|
||||||
const status = await getPartyStatus(partyId);
|
const status = await getPartyStatus(result.partyId);
|
||||||
|
broadcastPartyEvent(result.partyId, {
|
||||||
|
type: "member_left",
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
if (result.newHostId) {
|
||||||
|
broadcastPartyEvent(result.partyId, {
|
||||||
|
type: "host_changed",
|
||||||
|
hostId: result.newHostId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
broadcastSnapshot(result.partyId, status);
|
||||||
return status ?? { party: null, members: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
|
|
@ -232,6 +427,12 @@ export const partyApp = new Elysia()
|
||||||
await cleanupPartyIfEmpty(tx, currentMembership.partyId);
|
await cleanupPartyIfEmpty(tx, currentMembership.partyId);
|
||||||
});
|
});
|
||||||
const status = await getPartyStatus(currentMembership.partyId);
|
const status = await getPartyStatus(currentMembership.partyId);
|
||||||
|
broadcastPartyEvent(currentMembership.partyId, {
|
||||||
|
type: "member_left",
|
||||||
|
userId: body.memberUserId,
|
||||||
|
kickedBy: user.id,
|
||||||
|
});
|
||||||
|
broadcastSnapshot(currentMembership.partyId, status);
|
||||||
return status ?? { party: null, members: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -285,6 +486,7 @@ export const partyApp = new Elysia()
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = await getPartyStatus(currentMembership.partyId);
|
const status = await getPartyStatus(currentMembership.partyId);
|
||||||
|
broadcastSnapshot(currentMembership.partyId, status);
|
||||||
return status ?? { party: null, members: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Elysia from "elysia";
|
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
import Elysia from "elysia";
|
||||||
import { betterAuthElysia } from "../auth";
|
import { betterAuthElysia } from "../auth";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { DBOS, ConfiguredInstance } from "@dbos-inc/dbos-sdk";
|
import { ConfiguredInstance, DBOS } from "@dbos-inc/dbos-sdk";
|
||||||
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
|
||||||
import type {
|
import type {
|
||||||
Artist,
|
Artist,
|
||||||
PlayHistory,
|
PlayHistory,
|
||||||
|
|
@ -7,6 +6,8 @@ import type {
|
||||||
SavedTrack,
|
SavedTrack,
|
||||||
Track,
|
Track,
|
||||||
} from "@spotify/web-api-ts-sdk";
|
} from "@spotify/web-api-ts-sdk";
|
||||||
|
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { auth, SPOTIFY_CLIENT_ID } from "../auth";
|
import { auth, SPOTIFY_CLIENT_ID } from "../auth";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,7 +25,6 @@ import {
|
||||||
upsertTopArtists,
|
upsertTopArtists,
|
||||||
upsertTopTracks,
|
upsertTopTracks,
|
||||||
} from "../db/spotify";
|
} from "../db/spotify";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
const timelines = ["short_term", "medium_term", "long_term"] as const;
|
const timelines = ["short_term", "medium_term", "long_term"] as const;
|
||||||
type Timeline = (typeof timelines)[number];
|
type Timeline = (typeof timelines)[number];
|
||||||
|
|
@ -39,10 +39,6 @@ type SyncPayload = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class SpotifySyncWorkflow extends ConfiguredInstance {
|
export class SpotifySyncWorkflow extends ConfiguredInstance {
|
||||||
constructor(name: string) {
|
|
||||||
super(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DBOS.workflow()
|
@DBOS.workflow()
|
||||||
async syncUser(userId: string) {
|
async syncUser(userId: string) {
|
||||||
console.log("Sync start");
|
console.log("Sync start");
|
||||||
|
|
@ -186,7 +182,8 @@ export class SpotifySyncWorkflow extends ConfiguredInstance {
|
||||||
const artists = page.artists;
|
const artists = page.artists;
|
||||||
followed.push(...artists.items);
|
followed.push(...artists.items);
|
||||||
if (!artists.next || artists.items.length === 0) break;
|
if (!artists.next || artists.items.length === 0) break;
|
||||||
after = artists.items[artists.items.length - 1]!.id;
|
const lastArtist = artists.items.at(-1);
|
||||||
|
after = lastArtist?.id;
|
||||||
}
|
}
|
||||||
return followed;
|
return followed;
|
||||||
}
|
}
|
||||||
|
|
@ -235,8 +232,12 @@ export class SpotifySyncWorkflow extends ConfiguredInstance {
|
||||||
});
|
});
|
||||||
return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, {
|
return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, {
|
||||||
access_token: accessToken.accessToken,
|
access_token: accessToken.accessToken,
|
||||||
expires_in: Date.now() - Number(accessToken.accessTokenExpiresAt!),
|
expires_in: accessToken.accessTokenExpiresAt
|
||||||
expires: Number(accessToken.accessTokenExpiresAt),
|
? Date.now() - Number(accessToken.accessTokenExpiresAt)
|
||||||
|
: 0,
|
||||||
|
expires: accessToken.accessTokenExpiresAt
|
||||||
|
? Number(accessToken.accessTokenExpiresAt)
|
||||||
|
: 0,
|
||||||
refresh_token: "",
|
refresh_token: "",
|
||||||
token_type: "",
|
token_type: "",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,6 @@
|
||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue