From f79a9893a1cfab6eebe7a6798134a5dc32d69045 Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Tue, 21 Apr 2026 22:01:53 +0200 Subject: [PATCH] format issues + socket --- api/AGENTS.md | 6 + api/drizzle.config.ts | 20 +- api/package.json | 43 +- api/src/auth.ts | 94 ++-- api/src/db/auth-schema.ts | 142 ++--- api/src/db/index.ts | 8 +- api/src/db/schema.ts | 1027 +++++++++++++++++++------------------ api/src/db/spotify.ts | 844 +++++++++++++++--------------- api/src/dbos.ts | 4 +- api/src/index.ts | 14 +- api/src/party-sockets.ts | 85 +++ api/src/routes/party.ts | 712 ++++++++++++++++--------- api/src/routes/stats.ts | 150 +++--- api/src/routes/sync.ts | 14 +- api/src/workflows/sync.ts | 425 +++++++-------- api/tsconfig.json | 50 +- 16 files changed, 1984 insertions(+), 1654 deletions(-) create mode 100644 api/AGENTS.md create mode 100644 api/src/party-sockets.ts diff --git a/api/AGENTS.md b/api/AGENTS.md new file mode 100644 index 0000000..a48bd88 --- /dev/null +++ b/api/AGENTS.md @@ -0,0 +1,6 @@ +Run biome and typescript checks after your changes: + +``` +bun x biome ci +bun x tsc --noEmit +``` diff --git a/api/drizzle.config.ts b/api/drizzle.config.ts index ff5cc94..319a588 100644 --- a/api/drizzle.config.ts +++ b/api/drizzle.config.ts @@ -1,11 +1,17 @@ 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({ - out: "./drizzle", - schema: "./src/db/schema.ts", - dialect: "postgresql", - dbCredentials: { - url: process.env.DATABASE_URL!, - }, - schemaFilter: ["public"], + out: "./drizzle", + schema: "./src/db/schema.ts", + dialect: "postgresql", + dbCredentials: { + url: databaseUrl, + }, + schemaFilter: ["public"], }); diff --git a/api/package.json b/api/package.json index 1eca677..497c01a 100644 --- a/api/package.json +++ b/api/package.json @@ -1,23 +1,24 @@ { - "name": "api", - "module": "index.ts", - "type": "module", - "private": true, - "main": "src/index.ts", - "scripts": { - "dev": "bun run --watch src/index.ts" - }, - "devDependencies": { - "@types/bun": "latest" - }, - "peerDependencies": { - "typescript": "^5" - }, - "dependencies": { - "@dbos-inc/dbos-sdk": "^4.14.6", - "@spotify/web-api-ts-sdk": "^1.2.0", - "@statsfm/statsfm.js": "github.com:statsfm/statsfm.js", - "better-auth": "^1.6.5", - "elysia": "catalog:" - } + "name": "api", + "module": "index.ts", + "type": "module", + "private": true, + "main": "src/index.ts", + "scripts": { + "dev": "bun run --watch src/index.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@dbos-inc/dbos-sdk": "^4.14.6", + "@spotify/web-api-ts-sdk": "^1.2.0", + "@statsfm/statsfm.js": "github.com:statsfm/statsfm.js", + "better-auth": "^1.6.5", + "elysia": "catalog:" + } } diff --git a/api/src/auth.ts b/api/src/auth.ts index c1c1a2a..ababee0 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -1,59 +1,65 @@ +import { SpotifyApi } from "@spotify/web-api-ts-sdk"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import Elysia from "elysia"; import { db } from "./db"; -import Elysia, { status, type Context } from "elysia"; 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!; -export const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!; +const requireEnv = (name: string): string => { + 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( - SPOTIFY_CLIENT_ID, - SPOTIFY_CLIENT_SECRET, + SPOTIFY_CLIENT_ID, + SPOTIFY_CLIENT_SECRET, ); export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "pg", - schema, - }), - socialProviders: { - spotify: { - clientId: SPOTIFY_CLIENT_ID, - clientSecret: SPOTIFY_CLIENT_SECRET, - scope: [ - "user-read-playback-state", - "user-read-currently-playing", - "user-modify-playback-state", - "playlist-read-private", - "playlist-read-collaborative", - "user-follow-read", - "user-top-read", - "user-read-recently-played", - "user-library-read", - // "user-personalized", - "user-read-email", - ], - }, - }, + database: drizzleAdapter(db, { + provider: "pg", + schema, + }), + socialProviders: { + spotify: { + clientId: SPOTIFY_CLIENT_ID, + clientSecret: SPOTIFY_CLIENT_SECRET, + scope: [ + "user-read-playback-state", + "user-read-currently-playing", + "user-modify-playback-state", + "playlist-read-private", + "playlist-read-collaborative", + "user-follow-read", + "user-top-read", + "user-read-recently-played", + "user-library-read", + // "user-personalized", + "user-read-email", + ], + }, + }, }); export const betterAuthElysia = new Elysia({ name: "better-auth" }) - .mount(auth.handler) - .macro({ - auth: { - async resolve({ status, request: { headers } }) { - const session = await auth.api.getSession({ - headers, - }); + .mount(auth.handler) + .macro({ + auth: { + async resolve({ status, request: { headers } }) { + const session = await auth.api.getSession({ + headers, + }); - if (!session) return status(401); + if (!session) return status(401); - return { - user: session.user, - session: session.session, - }; - }, - }, - }); + return { + user: session.user, + session: session.session, + }; + }, + }, + }); diff --git a/api/src/db/auth-schema.ts b/api/src/db/auth-schema.ts index 36ae570..708da79 100644 --- a/api/src/db/auth-schema.ts +++ b/api/src/db/auth-schema.ts @@ -1,93 +1,93 @@ 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", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified").default(false).notNull(), - image: text("image"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), }); export const session = pgTable( - "session", - { - id: text("id").primaryKey(), - expiresAt: timestamp("expires_at").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - }, - (table) => [index("session_userId_idx").on(table.userId)], + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [index("session_userId_idx").on(table.userId)], ); export const account = pgTable( - "account", - { - id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: timestamp("access_token_expires_at"), - refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), - scope: text("scope"), - password: text("password"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - }, - (table) => [index("account_userId_idx").on(table.userId)], + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], ); export const verification = pgTable( - "verification", - { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - }, - (table) => [index("verification_identifier_idx").on(table.identifier)], + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], ); export const userRelations = relations(user, ({ many }) => ({ - sessions: many(session), - accounts: many(account), + sessions: many(session), + accounts: many(account), })); export const sessionRelations = relations(session, ({ one }) => ({ - user: one(user, { - fields: [session.userId], - references: [user.id], - }), + user: one(user, { + fields: [session.userId], + references: [user.id], + }), })); export const accountRelations = relations(account, ({ one }) => ({ - user: one(user, { - fields: [account.userId], - references: [user.id], - }), + user: one(user, { + fields: [account.userId], + references: [user.id], + }), })); diff --git a/api/src/db/index.ts b/api/src/db/index.ts index f89c2b9..7b002a1 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -1,4 +1,10 @@ import { drizzle } from "drizzle-orm/node-postgres"; 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 }); diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index b939b65..9c603a7 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -1,589 +1,590 @@ import { defineRelations } from "drizzle-orm"; import { - boolean, - index, - integer, - json, - pgEnum, - pgTable, - primaryKey, - text, - timestamp, - uniqueIndex, - uuid, + boolean, + index, + integer, + json, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, } from "drizzle-orm/pg-core"; import { user } from "./auth-schema"; + export * from "./auth-schema"; export const partyStatus = pgEnum("party_status", [ - "created", - "started", - "ended", + "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(), + 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", + "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)], + "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", { - id: uuid().defaultRandom().primaryKey().notNull(), - platform_id: text().notNull(), - platform: platform().notNull(), - name: text().notNull(), - type: text(), - popularity: integer(), + id: uuid().defaultRandom().primaryKey().notNull(), + platform_id: text().notNull(), + platform: platform().notNull(), + name: text().notNull(), + type: text(), + popularity: integer(), }); export const genre = pgTable("genre", { - id: uuid().defaultRandom().primaryKey().notNull(), - name: text().notNull().unique(), + id: uuid().defaultRandom().primaryKey().notNull(), + name: text().notNull().unique(), }); export const artistGenre = pgTable( - "artist_genre", - { - artistId: uuid() - .references(() => artist.id) - .notNull(), - genreId: uuid() - .references(() => genre.id) - .notNull(), - }, - (artistGenres) => [ - primaryKey({ - columns: [artistGenres.artistId, artistGenres.genreId], - }), - ], + "artist_genre", + { + artistId: uuid() + .references(() => artist.id) + .notNull(), + genreId: uuid() + .references(() => genre.id) + .notNull(), + }, + (artistGenres) => [ + primaryKey({ + columns: [artistGenres.artistId, artistGenres.genreId], + }), + ], ); export const platformImage = pgTable( - "platform_image", - { - id: uuid().defaultRandom().primaryKey().notNull(), - platform: platform().notNull(), - url: text(), - height: integer(), - width: integer(), - }, - (platformImage) => [ - uniqueIndex().on(platformImage.platform, platformImage.url), - index().on(platformImage.url), - ], + "platform_image", + { + id: uuid().defaultRandom().primaryKey().notNull(), + platform: platform().notNull(), + url: text(), + height: integer(), + width: integer(), + }, + (platformImage) => [ + uniqueIndex().on(platformImage.platform, platformImage.url), + index().on(platformImage.url), + ], ); export const artistImage = pgTable( - "artist_image", - { - artistId: uuid() - .references(() => artist.id) - .notNull(), - imageId: uuid() - .references(() => platformImage.id) - .notNull(), - }, - (artistImages) => [ - primaryKey({ - columns: [artistImages.artistId, artistImages.imageId], - }), - ], + "artist_image", + { + artistId: uuid() + .references(() => artist.id) + .notNull(), + imageId: uuid() + .references(() => platformImage.id) + .notNull(), + }, + (artistImages) => [ + primaryKey({ + columns: [artistImages.artistId, artistImages.imageId], + }), + ], ); export const album = pgTable("album", { - id: uuid().defaultRandom().primaryKey().notNull(), - type: text(), - name: text(), - platform: platform(), - platform_id: text(), - popularity: integer(), - release_date: timestamp(), - label: text(), + id: uuid().defaultRandom().primaryKey().notNull(), + type: text(), + name: text(), + platform: platform(), + platform_id: text(), + popularity: integer(), + release_date: timestamp(), + label: text(), }); export const albumImage = pgTable( - "album_image", - { - albumId: uuid() - .references(() => album.id) - .notNull(), - imageId: uuid() - .references(() => platformImage.id) - .notNull(), - }, - (albumImage) => [ - primaryKey({ - columns: [albumImage.albumId, albumImage.imageId], - }), - ], + "album_image", + { + albumId: uuid() + .references(() => album.id) + .notNull(), + imageId: uuid() + .references(() => platformImage.id) + .notNull(), + }, + (albumImage) => [ + primaryKey({ + columns: [albumImage.albumId, albumImage.imageId], + }), + ], ); export const albumArtist = pgTable( - "album_artist", - { - albumId: uuid() - .references(() => album.id) - .notNull(), - artistId: uuid() - .references(() => artist.id) - .notNull(), - }, - (albumArtist) => [ - primaryKey({ - columns: [albumArtist.albumId, albumArtist.artistId], - }), - ], + "album_artist", + { + albumId: uuid() + .references(() => album.id) + .notNull(), + artistId: uuid() + .references(() => artist.id) + .notNull(), + }, + (albumArtist) => [ + primaryKey({ + columns: [albumArtist.albumId, albumArtist.artistId], + }), + ], ); export const albumGenre = pgTable( - "album_genre", - { - albumId: uuid() - .references(() => album.id) - .notNull(), - genreId: uuid() - .references(() => genre.id) - .notNull(), - }, - (albumGenre) => [ - primaryKey({ - columns: [albumGenre.albumId, albumGenre.genreId], - }), - ], + "album_genre", + { + albumId: uuid() + .references(() => album.id) + .notNull(), + genreId: uuid() + .references(() => genre.id) + .notNull(), + }, + (albumGenre) => [ + primaryKey({ + columns: [albumGenre.albumId, albumGenre.genreId], + }), + ], ); export const track = pgTable( - "track", - { - id: uuid().defaultRandom().primaryKey().notNull(), - albumId: uuid() - .references(() => album.id) - .notNull(), - name: text(), - platform: platform(), - platform_id: text(), - popularity: integer(), - duration: integer(), - explicit: boolean(), - disc_number: integer(), - track_number: integer(), - }, - (track) => [index().on(track.albumId)], + "track", + { + id: uuid().defaultRandom().primaryKey().notNull(), + albumId: uuid() + .references(() => album.id) + .notNull(), + name: text(), + platform: platform(), + platform_id: text(), + popularity: integer(), + duration: integer(), + explicit: boolean(), + disc_number: integer(), + track_number: integer(), + }, + (track) => [index().on(track.albumId)], ); export const trackArtist = pgTable( - "track_artist", - { - trackId: uuid() - .references(() => track.id) - .notNull(), - artistId: uuid() - .references(() => artist.id) - .notNull(), - }, - (trackArtist) => [ - primaryKey({ - columns: [trackArtist.trackId, trackArtist.artistId], - }), - index("track_artist_track_id_idx").on(trackArtist.trackId), - index("track_artist_artist_id_idx").on(trackArtist.artistId), - ], + "track_artist", + { + trackId: uuid() + .references(() => track.id) + .notNull(), + artistId: uuid() + .references(() => artist.id) + .notNull(), + }, + (trackArtist) => [ + primaryKey({ + columns: [trackArtist.trackId, trackArtist.artistId], + }), + index("track_artist_track_id_idx").on(trackArtist.trackId), + index("track_artist_artist_id_idx").on(trackArtist.artistId), + ], ); export const topTimeline = pgEnum("top_timeline", [ - "short_term", - "medium_term", - "long_term", + "short_term", + "medium_term", + "long_term", ]); export const topTrack = pgTable( - "top_track", - { - trackId: uuid() - .references(() => track.id) - .notNull(), - position: integer().notNull(), - userId: text() - .references(() => user.id) - .notNull(), - timeline: topTimeline().notNull(), - }, - (topTrack) => [ - primaryKey({ - columns: [topTrack.trackId, topTrack.userId, topTrack.timeline], - }), - index().on(topTrack.userId), - ], + "top_track", + { + trackId: uuid() + .references(() => track.id) + .notNull(), + position: integer().notNull(), + userId: text() + .references(() => user.id) + .notNull(), + timeline: topTimeline().notNull(), + }, + (topTrack) => [ + primaryKey({ + columns: [topTrack.trackId, topTrack.userId, topTrack.timeline], + }), + index().on(topTrack.userId), + ], ); export const topArtist = pgTable( - "top_artist", - { - artistId: uuid() - .references(() => artist.id) - .notNull(), - position: integer().notNull(), - userId: text() - .references(() => user.id) - .notNull(), - timeline: topTimeline().notNull(), - }, - (topArtist) => [ - primaryKey({ - columns: [topArtist.artistId, topArtist.userId, topArtist.timeline], - }), - index().on(topArtist.userId), - ], + "top_artist", + { + artistId: uuid() + .references(() => artist.id) + .notNull(), + position: integer().notNull(), + userId: text() + .references(() => user.id) + .notNull(), + timeline: topTimeline().notNull(), + }, + (topArtist) => [ + primaryKey({ + columns: [topArtist.artistId, topArtist.userId, topArtist.timeline], + }), + index().on(topArtist.userId), + ], ); export const savedAlbum = pgTable( - "saved_album", - { - albumId: uuid() - .references(() => album.id) - .notNull(), - userId: text() - .references(() => user.id) - .notNull(), - saved_at: timestamp("saved_at").defaultNow().notNull(), - }, - (savedAlbum) => [ - primaryKey({ - columns: [savedAlbum.albumId, savedAlbum.userId], - }), - index().on(savedAlbum.userId), - ], + "saved_album", + { + albumId: uuid() + .references(() => album.id) + .notNull(), + userId: text() + .references(() => user.id) + .notNull(), + saved_at: timestamp("saved_at").defaultNow().notNull(), + }, + (savedAlbum) => [ + primaryKey({ + columns: [savedAlbum.albumId, savedAlbum.userId], + }), + index().on(savedAlbum.userId), + ], ); export const savedTrack = pgTable( - "saved_track", - { - trackId: uuid() - .references(() => track.id) - .notNull(), - userId: text() - .references(() => user.id) - .notNull(), - saved_at: timestamp("saved_at").defaultNow().notNull(), - }, - (savedTrack) => [ - primaryKey({ - columns: [savedTrack.trackId, savedTrack.userId], - }), - index().on(savedTrack.userId), - ], + "saved_track", + { + trackId: uuid() + .references(() => track.id) + .notNull(), + userId: text() + .references(() => user.id) + .notNull(), + saved_at: timestamp("saved_at").defaultNow().notNull(), + }, + (savedTrack) => [ + primaryKey({ + columns: [savedTrack.trackId, savedTrack.userId], + }), + index().on(savedTrack.userId), + ], ); export const followedArtist = pgTable( - "followed_artist", - { - artistId: uuid() - .references(() => artist.id) - .notNull(), - userId: text() - .references(() => user.id) - .notNull(), - }, - (followedArtist) => [ - primaryKey({ - columns: [followedArtist.artistId, followedArtist.userId], - }), - index().on(followedArtist.userId), - ], + "followed_artist", + { + artistId: uuid() + .references(() => artist.id) + .notNull(), + userId: text() + .references(() => user.id) + .notNull(), + }, + (followedArtist) => [ + primaryKey({ + columns: [followedArtist.artistId, followedArtist.userId], + }), + index().on(followedArtist.userId), + ], ); export const playbackHistory = pgTable( - "playback_history", - { - id: uuid().defaultRandom().primaryKey().notNull(), - trackId: uuid() - .references(() => track.id) - .notNull(), - userId: text() - .references(() => user.id) - .notNull(), - played_at: timestamp("played_at").defaultNow().notNull(), - }, - (playbackHistory) => [index().on(playbackHistory.userId)], + "playback_history", + { + id: uuid().defaultRandom().primaryKey().notNull(), + trackId: uuid() + .references(() => track.id) + .notNull(), + userId: text() + .references(() => user.id) + .notNull(), + played_at: timestamp("played_at").defaultNow().notNull(), + }, + (playbackHistory) => [index().on(playbackHistory.userId)], ); export const relations = defineRelations( - { - album, - albumImage, - albumArtist, - albumGenre, - artist, - artistGenre, - artistImage, - followedArtist, - genre, - party, - partyMember, - platformImage, - playbackHistory, - savedAlbum, - savedTrack, - topArtist, - topTrack, - track, - trackArtist, - user, - }, - (r) => ({ - artist: { - artistGenres: r.many.artistGenre(), - artistImages: r.many.artistImage(), - trackArtists: r.many.trackArtist(), - albumArtists: r.many.albumArtist(), - topArtists: r.many.topArtist(), - followedArtists: r.many.followedArtist(), - genres: r.many.genre({ - from: r.artist.id.through(r.artistGenre.artistId), - to: r.genre.id.through(r.artistGenre.genreId), - }), - images: r.many.platformImage({ - from: r.artist.id.through(r.artistImage.artistId), - to: r.platformImage.id.through(r.artistImage.imageId), - }), - albums: r.many.album({ - from: r.artist.id.through(r.albumArtist.artistId), - to: r.album.id.through(r.albumArtist.albumId), - }), - tracks: r.many.track({ - from: r.artist.id.through(r.trackArtist.artistId), - to: r.track.id.through(r.trackArtist.trackId), - }), - }, - genre: { - artistGenres: r.many.artistGenre(), - albumGenres: r.many.albumGenre(), - artists: r.many.artist({ - from: r.genre.id.through(r.artistGenre.genreId), - to: r.artist.id.through(r.artistGenre.artistId), - }), - albums: r.many.album({ - from: r.genre.id.through(r.albumGenre.genreId), - to: r.album.id.through(r.albumGenre.albumId), - }), - }, - artistGenre: { - artist: r.one.artist({ - from: r.artistGenre.artistId, - to: r.artist.id, - }), - genre: r.one.genre({ - from: r.artistGenre.genreId, - to: r.genre.id, - }), - }, - platformImage: { - artistImages: r.many.artistImage(), - albumImages: r.many.albumImage(), - artists: r.many.artist({ - from: r.platformImage.id.through(r.artistImage.imageId), - to: r.artist.id.through(r.artistImage.artistId), - }), - albums: r.many.album({ - from: r.platformImage.id.through(r.albumImage.imageId), - 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, - to: r.artist.id, - }), - image: r.one.platformImage({ - from: r.artistImage.imageId, - to: r.platformImage.id, - }), - }, - album: { - tracks: r.many.track(), - albumImages: r.many.albumImage(), - albumArtists: r.many.albumArtist(), - albumGenres: r.many.albumGenre(), - savedAlbums: r.many.savedAlbum(), - images: r.many.platformImage({ - from: r.album.id.through(r.albumImage.albumId), - to: r.platformImage.id.through(r.albumImage.imageId), - }), - artists: r.many.artist({ - from: r.album.id.through(r.albumArtist.albumId), - to: r.artist.id.through(r.albumArtist.artistId), - }), - genres: r.many.genre({ - from: r.album.id.through(r.albumGenre.albumId), - to: r.genre.id.through(r.albumGenre.genreId), - }), - }, - albumImage: { - album: r.one.album({ - from: r.albumImage.albumId, - to: r.album.id, - }), - image: r.one.platformImage({ - from: r.albumImage.imageId, - to: r.platformImage.id, - }), - }, - albumArtist: { - album: r.one.album({ - from: r.albumArtist.albumId, - to: r.album.id, - }), - artist: r.one.artist({ - from: r.albumArtist.artistId, - to: r.artist.id, - }), - }, - albumGenre: { - album: r.one.album({ - from: r.albumGenre.albumId, - to: r.album.id, - }), - genre: r.one.genre({ - from: r.albumGenre.genreId, - to: r.genre.id, - }), - }, - track: { - album: r.one.album({ - from: r.track.albumId, - to: r.album.id, - }), - trackArtists: r.many.trackArtist(), - topTracks: r.many.topTrack(), - savedTracks: r.many.savedTrack(), - playbackHistory: r.many.playbackHistory(), - artists: r.many.artist({ - from: r.track.id.through(r.trackArtist.trackId), - to: r.artist.id.through(r.trackArtist.artistId), - }), - }, - trackArtist: { - track: r.one.track({ - from: r.trackArtist.trackId, - to: r.track.id, - }), - artist: r.one.artist({ - from: r.trackArtist.artistId, - to: r.artist.id, - }), - }, - topTrack: { - track: r.one.track({ - from: r.topTrack.trackId, - to: r.track.id, - }), - user: r.one.user({ - from: r.topTrack.userId, - to: r.user.id, - }), - }, - topArtist: { - artist: r.one.artist({ - from: r.topArtist.artistId, - to: r.artist.id, - }), - user: r.one.user({ - from: r.topArtist.userId, - to: r.user.id, - }), - }, - savedAlbum: { - album: r.one.album({ - from: r.savedAlbum.albumId, - to: r.album.id, - }), - user: r.one.user({ - from: r.savedAlbum.userId, - to: r.user.id, - }), - }, - savedTrack: { - track: r.one.track({ - from: r.savedTrack.trackId, - to: r.track.id, - }), - user: r.one.user({ - from: r.savedTrack.userId, - to: r.user.id, - }), - }, - followedArtist: { - artist: r.one.artist({ - from: r.followedArtist.artistId, - to: r.artist.id, - }), - user: r.one.user({ - from: r.followedArtist.userId, - to: r.user.id, - }), - }, - playbackHistory: { - track: r.one.track({ - from: r.playbackHistory.trackId, - to: r.track.id, - }), - user: r.one.user({ - from: r.playbackHistory.userId, - to: r.user.id, - }), - }, - user: { - partyMembers: r.many.partyMember(), - hostedParties: r.many.party({ - from: r.user.id, - to: r.party.hostId, - }), - }, - }), + { + album, + albumImage, + albumArtist, + albumGenre, + artist, + artistGenre, + artistImage, + followedArtist, + genre, + party, + partyMember, + platformImage, + playbackHistory, + savedAlbum, + savedTrack, + topArtist, + topTrack, + track, + trackArtist, + user, + }, + (r) => ({ + artist: { + artistGenres: r.many.artistGenre(), + artistImages: r.many.artistImage(), + trackArtists: r.many.trackArtist(), + albumArtists: r.many.albumArtist(), + topArtists: r.many.topArtist(), + followedArtists: r.many.followedArtist(), + genres: r.many.genre({ + from: r.artist.id.through(r.artistGenre.artistId), + to: r.genre.id.through(r.artistGenre.genreId), + }), + images: r.many.platformImage({ + from: r.artist.id.through(r.artistImage.artistId), + to: r.platformImage.id.through(r.artistImage.imageId), + }), + albums: r.many.album({ + from: r.artist.id.through(r.albumArtist.artistId), + to: r.album.id.through(r.albumArtist.albumId), + }), + tracks: r.many.track({ + from: r.artist.id.through(r.trackArtist.artistId), + to: r.track.id.through(r.trackArtist.trackId), + }), + }, + genre: { + artistGenres: r.many.artistGenre(), + albumGenres: r.many.albumGenre(), + artists: r.many.artist({ + from: r.genre.id.through(r.artistGenre.genreId), + to: r.artist.id.through(r.artistGenre.artistId), + }), + albums: r.many.album({ + from: r.genre.id.through(r.albumGenre.genreId), + to: r.album.id.through(r.albumGenre.albumId), + }), + }, + artistGenre: { + artist: r.one.artist({ + from: r.artistGenre.artistId, + to: r.artist.id, + }), + genre: r.one.genre({ + from: r.artistGenre.genreId, + to: r.genre.id, + }), + }, + platformImage: { + artistImages: r.many.artistImage(), + albumImages: r.many.albumImage(), + artists: r.many.artist({ + from: r.platformImage.id.through(r.artistImage.imageId), + to: r.artist.id.through(r.artistImage.artistId), + }), + albums: r.many.album({ + from: r.platformImage.id.through(r.albumImage.imageId), + 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, + to: r.artist.id, + }), + image: r.one.platformImage({ + from: r.artistImage.imageId, + to: r.platformImage.id, + }), + }, + album: { + tracks: r.many.track(), + albumImages: r.many.albumImage(), + albumArtists: r.many.albumArtist(), + albumGenres: r.many.albumGenre(), + savedAlbums: r.many.savedAlbum(), + images: r.many.platformImage({ + from: r.album.id.through(r.albumImage.albumId), + to: r.platformImage.id.through(r.albumImage.imageId), + }), + artists: r.many.artist({ + from: r.album.id.through(r.albumArtist.albumId), + to: r.artist.id.through(r.albumArtist.artistId), + }), + genres: r.many.genre({ + from: r.album.id.through(r.albumGenre.albumId), + to: r.genre.id.through(r.albumGenre.genreId), + }), + }, + albumImage: { + album: r.one.album({ + from: r.albumImage.albumId, + to: r.album.id, + }), + image: r.one.platformImage({ + from: r.albumImage.imageId, + to: r.platformImage.id, + }), + }, + albumArtist: { + album: r.one.album({ + from: r.albumArtist.albumId, + to: r.album.id, + }), + artist: r.one.artist({ + from: r.albumArtist.artistId, + to: r.artist.id, + }), + }, + albumGenre: { + album: r.one.album({ + from: r.albumGenre.albumId, + to: r.album.id, + }), + genre: r.one.genre({ + from: r.albumGenre.genreId, + to: r.genre.id, + }), + }, + track: { + album: r.one.album({ + from: r.track.albumId, + to: r.album.id, + }), + trackArtists: r.many.trackArtist(), + topTracks: r.many.topTrack(), + savedTracks: r.many.savedTrack(), + playbackHistory: r.many.playbackHistory(), + artists: r.many.artist({ + from: r.track.id.through(r.trackArtist.trackId), + to: r.artist.id.through(r.trackArtist.artistId), + }), + }, + trackArtist: { + track: r.one.track({ + from: r.trackArtist.trackId, + to: r.track.id, + }), + artist: r.one.artist({ + from: r.trackArtist.artistId, + to: r.artist.id, + }), + }, + topTrack: { + track: r.one.track({ + from: r.topTrack.trackId, + to: r.track.id, + }), + user: r.one.user({ + from: r.topTrack.userId, + to: r.user.id, + }), + }, + topArtist: { + artist: r.one.artist({ + from: r.topArtist.artistId, + to: r.artist.id, + }), + user: r.one.user({ + from: r.topArtist.userId, + to: r.user.id, + }), + }, + savedAlbum: { + album: r.one.album({ + from: r.savedAlbum.albumId, + to: r.album.id, + }), + user: r.one.user({ + from: r.savedAlbum.userId, + to: r.user.id, + }), + }, + savedTrack: { + track: r.one.track({ + from: r.savedTrack.trackId, + to: r.track.id, + }), + user: r.one.user({ + from: r.savedTrack.userId, + to: r.user.id, + }), + }, + followedArtist: { + artist: r.one.artist({ + from: r.followedArtist.artistId, + to: r.artist.id, + }), + user: r.one.user({ + from: r.followedArtist.userId, + to: r.user.id, + }), + }, + playbackHistory: { + track: r.one.track({ + from: r.playbackHistory.trackId, + to: r.track.id, + }), + user: r.one.user({ + from: r.playbackHistory.userId, + 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/db/spotify.ts b/api/src/db/spotify.ts index 17bad18..709bb0c 100644 --- a/api/src/db/spotify.ts +++ b/api/src/db/spotify.ts @@ -1,488 +1,504 @@ import type { - Album, - Artist, - Image, - PlayHistory, - SavedAlbum, - SavedTrack, - SimplifiedAlbum, - SimplifiedArtist, - Track, + Album, + Artist, + Image, + PlayHistory, + SavedAlbum, + SavedTrack, + SimplifiedAlbum, + SimplifiedArtist, + Track, } from "@spotify/web-api-ts-sdk"; -import { db } from "."; -import { - album, - albumArtist, - albumGenre, - albumImage, - artist, - artistGenre, - artistImage, - followedArtist, - genre, - playbackHistory, - platformImage, - savedAlbum, - savedTrack, - topArtist, - topTrack, - track, - trackArtist, -} from "./schema"; import { and, eq, inArray, sql } from "drizzle-orm"; import { defaultSdk } from "../auth"; +import { db } from "."; +import { + album, + albumArtist, + albumGenre, + albumImage, + artist, + artistGenre, + artistImage, + followedArtist, + genre, + platformImage, + playbackHistory, + savedAlbum, + savedTrack, + topArtist, + topTrack, + track, + trackArtist, +} from "./schema"; export const PLATFORM_SPOTIFY = "spotify" as const; type DbClient = typeof db; type DbTransaction = Parameters[0] extends ( - tx: infer T, -) => Promise - ? T - : never; + tx: infer T, +) => Promise + ? T + : never; type DbLike = DbClient | DbTransaction; +const requireMapEntry = (map: Map, 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) { - await dbClient - .insert(platformImage) - .values( - images.map(({ url, height, width }) => ({ - platform: PLATFORM_SPOTIFY, - url, - height, - width, - })), - ) - .onConflictDoNothing(); + await dbClient + .insert(platformImage) + .values( + images.map(({ url, height, width }) => ({ + platform: PLATFORM_SPOTIFY, + url, + height, + width, + })), + ) + .onConflictDoNothing(); } export async function upsertGenres(genres: string[], dbClient: DbLike = db) { - const values = genres.filter(Boolean).map((name) => ({ name })); - if (values.length === 0) return; - await dbClient.insert(genre).values(values).onConflictDoNothing(); + const values = genres.filter(Boolean).map((name) => ({ name })); + if (values.length === 0) return; + await dbClient.insert(genre).values(values).onConflictDoNothing(); } export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) { - await dbClient - .insert(artist) - .values( - artists.map(({ id, name, images, genres, popularity, type }) => ({ - platform: PLATFORM_SPOTIFY, - platform_id: id, - name, - popularity, - type, - })), - ) - .onConflictDoNothing(); - await upsertImages( - artists.flatMap((a) => a.images), - dbClient, - ); - await upsertGenres( - artists.flatMap((a) => a.genres), - dbClient, - ); - for (const spotifyArtist of artists) { - await dbClient - .insert(artistImage) - .select( - dbClient - .select({ - artistId: artist.id, - imageId: platformImage.id, - }) - .from(platformImage) - .where( - and( - eq(platformImage.platform, PLATFORM_SPOTIFY), - inArray( - platformImage.url, - spotifyArtist.images.map((t) => t.url), - ), - ), - ) - .innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)), - ) - .onConflictDoNothing(); - await dbClient - .insert(artistGenre) - .select( - dbClient - .select({ - artistId: artist.id, - genreId: genre.id, - }) - .from(genre) - .where(inArray(genre.name, spotifyArtist.genres)) - .innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)), - ) - .onConflictDoNothing(); - } + await dbClient + .insert(artist) + .values( + artists.map(({ id, name, popularity, type }) => ({ + platform: PLATFORM_SPOTIFY, + platform_id: id, + name, + popularity, + type, + })), + ) + .onConflictDoNothing(); + await upsertImages( + artists.flatMap((a) => a.images), + dbClient, + ); + await upsertGenres( + artists.flatMap((a) => a.genres), + dbClient, + ); + for (const spotifyArtist of artists) { + await dbClient + .insert(artistImage) + .select( + dbClient + .select({ + artistId: artist.id, + imageId: platformImage.id, + }) + .from(platformImage) + .where( + and( + eq(platformImage.platform, PLATFORM_SPOTIFY), + inArray( + platformImage.url, + spotifyArtist.images.map((t) => t.url), + ), + ), + ) + .innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)), + ) + .onConflictDoNothing(); + await dbClient + .insert(artistGenre) + .select( + dbClient + .select({ + artistId: artist.id, + genreId: genre.id, + }) + .from(genre) + .where(inArray(genre.name, spotifyArtist.genres)) + .innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)), + ) + .onConflictDoNothing(); + } } async function getMissingArtists(artistIds: string[], dbClient: DbLike = db) { - const existingArtists = await dbClient - .select({ id: artist.id }) - .from(artist) - .where(inArray(artist.platform_id, artistIds)); - return artistIds.filter((id) => !existingArtists.some((a) => a.id === id)); + const existingArtists = await dbClient + .select({ id: artist.id }) + .from(artist) + .where(inArray(artist.platform_id, artistIds)); + return artistIds.filter((id) => !existingArtists.some((a) => a.id === id)); } async function lookupMissingArtists( - artistIds: string[], - dbClient: DbLike = db, + artistIds: string[], + dbClient: DbLike = db, ) { - const missingArtistIds = await getMissingArtists(artistIds, dbClient); - if (missingArtistIds.length === 0) return []; - let missingArtists: Artist[] = []; - for (let i = 0; i < missingArtistIds.length / 50; i++) { - missingArtists.push( - ...(await defaultSdk.artists.get( - missingArtistIds.slice(i * 50, (i + 1) * 50), - )), - ); - } - await upsertArtists(missingArtists, dbClient); - return missingArtists; + const missingArtistIds = await getMissingArtists(artistIds, dbClient); + if (missingArtistIds.length === 0) return []; + const missingArtists: Artist[] = []; + for (let i = 0; i < missingArtistIds.length / 50; i++) { + missingArtists.push( + ...(await defaultSdk.artists.get( + missingArtistIds.slice(i * 50, (i + 1) * 50), + )), + ); + } + await upsertArtists(missingArtists, dbClient); + return missingArtists; } function isFullArtistArray( - artists: Artist[] | SimplifiedArtist[], + artists: Artist[] | SimplifiedArtist[], ): artists is Artist[] { - return "images" in artists[0]!; + const firstArtist = artists[0]; + if (!firstArtist) return false; + return "images" in firstArtist; } async function upsertMissingArtists( - artists: SimplifiedArtist[] | Artist[], - dbClient: DbLike = db, + artists: SimplifiedArtist[] | Artist[], + dbClient: DbLike = db, ) { - if (artists.length === 0) return; - let missingArtists: Artist[]; - if (isFullArtistArray(artists)) { - const missingArtistIds = await getMissingArtists( - artists.map((t) => t.id), - dbClient, - ); - if (missingArtistIds.length === 0) return; - missingArtists = artists.filter((a) => missingArtistIds.includes(a.id)); - } else { - missingArtists = await lookupMissingArtists( - artists.map((t) => t.id), - dbClient, - ); - } - if (missingArtists.length === 0) return; - await upsertArtists(missingArtists, dbClient); - return missingArtists; + if (artists.length === 0) return; + let missingArtists: Artist[]; + if (isFullArtistArray(artists)) { + const missingArtistIds = await getMissingArtists( + artists.map((t) => t.id), + dbClient, + ); + if (missingArtistIds.length === 0) return; + missingArtists = artists.filter((a) => missingArtistIds.includes(a.id)); + } else { + missingArtists = await lookupMissingArtists( + artists.map((t) => t.id), + dbClient, + ); + } + if (missingArtists.length === 0) return; + await upsertArtists(missingArtists, dbClient); + return missingArtists; } async function getArtistIdMap(artistIds: string[], dbClient: DbLike = db) { - if (artistIds.length === 0) return new Map(); - const rows = await dbClient - .select({ id: artist.id, platform_id: artist.platform_id }) - .from(artist) - .where(inArray(artist.platform_id, artistIds)); - return new Map(rows.map((row) => [row.platform_id, row.id])); + if (artistIds.length === 0) return new Map(); + const rows = await dbClient + .select({ id: artist.id, platform_id: artist.platform_id }) + .from(artist) + .where(inArray(artist.platform_id, artistIds)); + return new Map(rows.map((row) => [row.platform_id, row.id])); } async function getAlbumIdMap(albumIds: string[], dbClient: DbLike = db) { - if (albumIds.length === 0) return new Map(); - const rows = await dbClient - .select({ id: album.id, platform_id: album.platform_id }) - .from(album) - .where(inArray(album.platform_id, albumIds)); - return new Map(rows.map((row) => [row.platform_id ?? "", row.id])); + if (albumIds.length === 0) return new Map(); + const rows = await dbClient + .select({ id: album.id, platform_id: album.platform_id }) + .from(album) + .where(inArray(album.platform_id, albumIds)); + 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) { - if (trackIds.length === 0) return new Map(); - const rows = await dbClient - .select({ id: track.id, platform_id: track.platform_id }) - .from(track) - .where(inArray(track.platform_id, trackIds)); - return new Map(rows.map((row) => [row.platform_id ?? "", row.id])); + if (trackIds.length === 0) return new Map(); + const rows = await dbClient + .select({ id: track.id, platform_id: track.platform_id }) + .from(track) + .where(inArray(track.platform_id, trackIds)); + return new Map( + rows + .filter((row) => row.platform_id) + .map((row) => [row.platform_id as string, row.id]), + ); } export async function upsertAlbums( - albums: Album[] | SimplifiedAlbum[], - dbClient: DbLike = db, + albums: Album[] | SimplifiedAlbum[], + dbClient: DbLike = db, ) { - await upsertMissingArtists( - albums.flatMap((a) => a.artists), - dbClient, - ); - await dbClient - .insert(album) - .values( - albums.map(({ id, name, type, popularity, release_date, label }) => ({ - platform: PLATFORM_SPOTIFY, - platform_id: id, - name, - type, - popularity, - release_date: new Date(release_date), - label, - })), - ) - .onConflictDoNothing(); - await upsertImages( - albums.flatMap((a) => a.images), - dbClient, - ); - await upsertGenres( - albums.flatMap((a) => a.genres), - dbClient, - ); - for (const spotifyAlbum of albums) { - await dbClient - .insert(albumImage) - .select( - dbClient - .select({ - albumId: album.id, - imageId: platformImage.id, - }) - .from(platformImage) - .where( - and( - eq(platformImage.platform, PLATFORM_SPOTIFY), - inArray( - platformImage.url, - spotifyAlbum.images.map((t) => t.url), - ), - ), - ) - .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), - ) - .onConflictDoNothing(); - await dbClient - .insert(albumArtist) - .select( - dbClient - .select({ - albumId: album.id, - artistId: artist.id, - }) - .from(artist) - .where( - inArray( - artist.platform_id, - spotifyAlbum.artists.map((t) => t.id), - ), - ) - .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), - ) - .onConflictDoNothing(); - if (spotifyAlbum.genres?.length > 0) - await dbClient - .insert(albumGenre) - .select( - dbClient - .select({ - albumId: album.id, - genreId: genre.id, - }) - .from(genre) - .where(inArray(genre.name, sql`${spotifyAlbum.genres}`)) - .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), - ) - .onConflictDoNothing(); - } + await upsertMissingArtists( + albums.flatMap((a) => a.artists), + dbClient, + ); + await dbClient + .insert(album) + .values( + albums.map(({ id, name, type, popularity, release_date, label }) => ({ + platform: PLATFORM_SPOTIFY, + platform_id: id, + name, + type, + popularity, + release_date: new Date(release_date), + label, + })), + ) + .onConflictDoNothing(); + await upsertImages( + albums.flatMap((a) => a.images), + dbClient, + ); + await upsertGenres( + albums.flatMap((a) => a.genres), + dbClient, + ); + for (const spotifyAlbum of albums) { + await dbClient + .insert(albumImage) + .select( + dbClient + .select({ + albumId: album.id, + imageId: platformImage.id, + }) + .from(platformImage) + .where( + and( + eq(platformImage.platform, PLATFORM_SPOTIFY), + inArray( + platformImage.url, + spotifyAlbum.images.map((t) => t.url), + ), + ), + ) + .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), + ) + .onConflictDoNothing(); + await dbClient + .insert(albumArtist) + .select( + dbClient + .select({ + albumId: album.id, + artistId: artist.id, + }) + .from(artist) + .where( + inArray( + artist.platform_id, + spotifyAlbum.artists.map((t) => t.id), + ), + ) + .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), + ) + .onConflictDoNothing(); + if (spotifyAlbum.genres?.length > 0) + await dbClient + .insert(albumGenre) + .select( + dbClient + .select({ + albumId: album.id, + genreId: genre.id, + }) + .from(genre) + .where(inArray(genre.name, sql`${spotifyAlbum.genres}`)) + .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), + ) + .onConflictDoNothing(); + } } export async function upsertTracks(tracks: Track[], dbClient: DbLike = db) { - if (tracks.length === 0) return; - await upsertAlbums( - tracks.map((t) => t.album), - dbClient, - ); - await upsertMissingArtists( - tracks.flatMap((t) => t.artists), - dbClient, - ); - const albumIdMap = await getAlbumIdMap( - tracks.map((t) => t.album.id), - dbClient, - ); - await dbClient - .insert(track) - .values( - tracks.map((spotifyTrack) => ({ - albumId: albumIdMap.get(spotifyTrack.album.id)!, - name: spotifyTrack.name, - platform: PLATFORM_SPOTIFY, - platform_id: spotifyTrack.id, - popularity: spotifyTrack.popularity, - duration: spotifyTrack.duration_ms, - explicit: spotifyTrack.explicit, - disc_number: spotifyTrack.disc_number, - track_number: spotifyTrack.track_number, - })), - ) - .onConflictDoNothing(); - for (const spotifyTrack of tracks) { - await dbClient - .insert(trackArtist) - .select( - dbClient - .select({ - trackId: track.id, - artistId: artist.id, - }) - .from(artist) - .where( - inArray( - artist.platform_id, - spotifyTrack.artists.map((t) => t.id), - ), - ) - .innerJoin(track, eq(track.platform_id, spotifyTrack.id)), - ) - .onConflictDoNothing(); - } + if (tracks.length === 0) return; + await upsertAlbums( + tracks.map((t) => t.album), + dbClient, + ); + await upsertMissingArtists( + tracks.flatMap((t) => t.artists), + dbClient, + ); + const albumIdMap = await getAlbumIdMap( + tracks.map((t) => t.album.id), + dbClient, + ); + await dbClient + .insert(track) + .values( + tracks.map((spotifyTrack) => ({ + albumId: requireMapEntry(albumIdMap, spotifyTrack.album.id, "albumId"), + name: spotifyTrack.name, + platform: PLATFORM_SPOTIFY, + platform_id: spotifyTrack.id, + popularity: spotifyTrack.popularity, + duration: spotifyTrack.duration_ms, + explicit: spotifyTrack.explicit, + disc_number: spotifyTrack.disc_number, + track_number: spotifyTrack.track_number, + })), + ) + .onConflictDoNothing(); + for (const spotifyTrack of tracks) { + await dbClient + .insert(trackArtist) + .select( + dbClient + .select({ + trackId: track.id, + artistId: artist.id, + }) + .from(artist) + .where( + inArray( + artist.platform_id, + spotifyTrack.artists.map((t) => t.id), + ), + ) + .innerJoin(track, eq(track.platform_id, spotifyTrack.id)), + ) + .onConflictDoNothing(); + } } export async function upsertTopArtists( - userId: string, - timeline: "short_term" | "medium_term" | "long_term", - artists: Artist[], - dbClient: DbLike = db, + userId: string, + timeline: "short_term" | "medium_term" | "long_term", + artists: Artist[], + dbClient: DbLike = db, ) { - if (artists.length === 0) return; - await upsertArtists(artists, dbClient); - const artistIdMap = await getArtistIdMap( - artists.map((t) => t.id), - dbClient, - ); - await dbClient - .insert(topArtist) - .values( - artists.map((spotifyArtist, index) => ({ - artistId: artistIdMap.get(spotifyArtist.id)!, - position: index + 1, - userId, - timeline, - })), - ) - .onConflictDoNothing(); + if (artists.length === 0) return; + await upsertArtists(artists, dbClient); + const artistIdMap = await getArtistIdMap( + artists.map((t) => t.id), + dbClient, + ); + await dbClient + .insert(topArtist) + .values( + artists.map((spotifyArtist, index) => ({ + artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"), + position: index + 1, + userId, + timeline, + })), + ) + .onConflictDoNothing(); } export async function upsertTopTracks( - userId: string, - timeline: "short_term" | "medium_term" | "long_term", - tracks: Track[], - dbClient: DbLike = db, + userId: string, + timeline: "short_term" | "medium_term" | "long_term", + tracks: Track[], + dbClient: DbLike = db, ) { - if (tracks.length === 0) return; - await upsertTracks(tracks, dbClient); - const trackIdMap = await getTrackIdMap( - tracks.map((t) => t.id), - dbClient, - ); - await dbClient - .insert(topTrack) - .values( - tracks.map((spotifyTrack, index) => ({ - trackId: trackIdMap.get(spotifyTrack.id)!, - position: index + 1, - userId, - timeline, - })), - ) - .onConflictDoNothing(); + if (tracks.length === 0) return; + await upsertTracks(tracks, dbClient); + const trackIdMap = await getTrackIdMap( + tracks.map((t) => t.id), + dbClient, + ); + await dbClient + .insert(topTrack) + .values( + tracks.map((spotifyTrack, index) => ({ + trackId: requireMapEntry(trackIdMap, spotifyTrack.id, "trackId"), + position: index + 1, + userId, + timeline, + })), + ) + .onConflictDoNothing(); } export async function upsertSavedAlbums( - userId: string, - saved: SavedAlbum[], - dbClient: DbLike = db, + userId: string, + saved: SavedAlbum[], + dbClient: DbLike = db, ) { - if (saved.length === 0) return; - const albums = saved.map((item) => item.album); - await upsertAlbums(albums, dbClient); - const albumIdMap = await getAlbumIdMap( - albums.map((t) => t.id), - dbClient, - ); - await dbClient - .insert(savedAlbum) - .values( - saved.map((item) => ({ - albumId: albumIdMap.get(item.album.id)!, - userId, - saved_at: new Date(item.added_at), - })), - ) - .onConflictDoNothing(); + if (saved.length === 0) return; + const albums = saved.map((item) => item.album); + await upsertAlbums(albums, dbClient); + const albumIdMap = await getAlbumIdMap( + albums.map((t) => t.id), + dbClient, + ); + await dbClient + .insert(savedAlbum) + .values( + saved.map((item) => ({ + albumId: requireMapEntry(albumIdMap, item.album.id, "albumId"), + userId, + saved_at: new Date(item.added_at), + })), + ) + .onConflictDoNothing(); } export async function upsertSavedTracks( - userId: string, - saved: SavedTrack[], - dbClient: DbLike = db, + userId: string, + saved: SavedTrack[], + dbClient: DbLike = db, ) { - if (saved.length === 0) return; - const tracks = saved.map((item) => item.track); - await upsertTracks(tracks, dbClient); - const trackIdMap = await getTrackIdMap( - tracks.map((t) => t.id), - dbClient, - ); - await dbClient - .insert(savedTrack) - .values( - saved.map((item) => ({ - trackId: trackIdMap.get(item.track.id)!, - userId, - saved_at: new Date(item.added_at), - })), - ) - .onConflictDoNothing(); + if (saved.length === 0) return; + const tracks = saved.map((item) => item.track); + await upsertTracks(tracks, dbClient); + const trackIdMap = await getTrackIdMap( + tracks.map((t) => t.id), + dbClient, + ); + await dbClient + .insert(savedTrack) + .values( + saved.map((item) => ({ + trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"), + userId, + saved_at: new Date(item.added_at), + })), + ) + .onConflictDoNothing(); } export async function upsertFollowedArtists( - userId: string, - artists: Artist[], - dbClient: DbLike = db, + userId: string, + artists: Artist[], + dbClient: DbLike = db, ) { - if (artists.length === 0) return; - await upsertArtists(artists, dbClient); - const artistIdMap = await getArtistIdMap( - artists.map((t) => t.id), - dbClient, - ); - await dbClient - .insert(followedArtist) - .values( - artists.map((spotifyArtist) => ({ - artistId: artistIdMap.get(spotifyArtist.id)!, - userId, - })), - ) - .onConflictDoNothing(); + if (artists.length === 0) return; + await upsertArtists(artists, dbClient); + const artistIdMap = await getArtistIdMap( + artists.map((t) => t.id), + dbClient, + ); + await dbClient + .insert(followedArtist) + .values( + artists.map((spotifyArtist) => ({ + artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"), + userId, + })), + ) + .onConflictDoNothing(); } export async function upsertPlaybackHistory( - userId: string, - items: PlayHistory[], - dbClient: DbLike = db, + userId: string, + items: PlayHistory[], + dbClient: DbLike = db, ) { - if (items.length === 0) return; - const tracks = items.map((item) => item.track); - await upsertTracks(tracks, dbClient); - const trackIdMap = await getTrackIdMap( - tracks.map((t) => t.id), - dbClient, - ); - await dbClient - .insert(playbackHistory) - .values( - items.map((item) => ({ - trackId: trackIdMap.get(item.track.id)!, - userId, - played_at: new Date(item.played_at), - })), - ) - .onConflictDoNothing(); + if (items.length === 0) return; + const tracks = items.map((item) => item.track); + await upsertTracks(tracks, dbClient); + const trackIdMap = await getTrackIdMap( + tracks.map((t) => t.id), + dbClient, + ); + await dbClient + .insert(playbackHistory) + .values( + items.map((item) => ({ + trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"), + userId, + played_at: new Date(item.played_at), + })), + ) + .onConflictDoNothing(); } diff --git a/api/src/dbos.ts b/api/src/dbos.ts index d1155d8..6afce72 100644 --- a/api/src/dbos.ts +++ b/api/src/dbos.ts @@ -2,6 +2,6 @@ import { DBOS } from "@dbos-inc/dbos-sdk"; import "./workflows/sync"; DBOS.setConfig({ - name: "itpdp", - systemDatabaseUrl: process.env.DATABASE_URL, + name: "itpdp", + systemDatabaseUrl: process.env.DATABASE_URL, }); diff --git a/api/src/index.ts b/api/src/index.ts index 7b170b9..a23f591 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,19 +1,19 @@ -import { Elysia, t } from "elysia"; +import { DBOS } from "@dbos-inc/dbos-sdk"; +import { Elysia } from "elysia"; import { betterAuthElysia } from "./auth"; import { syncApp } from "./routes/sync"; -import { DBOS } from "@dbos-inc/dbos-sdk"; import "./workflows/sync"; import "./dbos.ts"; -import { statsApp } from "./routes/stats.ts"; import { partyApp } from "./routes/party"; +import { statsApp } from "./routes/stats.ts"; const app = new Elysia() - .use(betterAuthElysia) - .group("/api", (app) => app.use(syncApp).use(statsApp).use(partyApp)) - .listen(4000); + .use(betterAuthElysia) + .group("/api", (app) => app.use(syncApp).use(statsApp).use(partyApp)) + .listen(4000); export type App = typeof app; await DBOS.launch({ - conductorKey: process.env.DBOS_CONDUCTOR_KEY, + conductorKey: process.env.DBOS_CONDUCTOR_KEY, }); diff --git a/api/src/party-sockets.ts b/api/src/party-sockets.ts new file mode 100644 index 0000000..6db0bd6 --- /dev/null +++ b/api/src/party-sockets.ts @@ -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>>(); + +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); + } +} diff --git a/api/src/routes/party.ts b/api/src/routes/party.ts index b34e68b..4e48af8 100644 --- a/api/src/routes/party.ts +++ b/api/src/routes/party.ts @@ -1,8 +1,14 @@ -import Elysia, { t } from "elysia"; 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 { party, partyMember } from "../db/schema"; +import { + broadcastPartyEvent, + registerPartySocket, + sendPartyEventToUser, + unregisterPartySocket, +} from "../party-sockets"; const PARTY_STATUS = ["created", "started", "ended"] as const; @@ -10,289 +16,485 @@ type PartyStatus = (typeof PARTY_STATUS)[number]; type DbClient = typeof db; type DbTransaction = Parameters[0] extends ( - tx: infer T, -) => Promise - ? T - : never; + tx: infer T, +) => Promise + ? T + : never; type DbLike = DbClient | DbTransaction; +type PartySnapshot = NonNullable>>; + +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) { - const memberships = await db.query.partyMember.findMany({ - where: { - userId, - }, - with: { - party: true, - }, - limit: 1, - }); - return memberships[0]?.party ?? null; + 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 - ); + 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, - }; + 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, + }; +} + +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) { - 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)); + 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; + 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", + }, + }); + let newHostId: string | null = null; + 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)); + newHostId = nextHost.userId; + } + } + await cleanupPartyIfEmpty(dbClient, member.partyId); + return { + partyId: member.partyId, + newHostId, + }; } function isValidStatus(status: string): status is PartyStatus { - return PARTY_STATUS.includes(status as 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." }; - } + .use(betterAuthElysia) + .group("/party", (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; + } - let partyId: string | null = null; - await db.transaction(async (tx) => { - await leaveParty(tx, user.id); + 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; + } - 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, - }); - } + if (message.type !== "member_payload") return; + const membership = await getMemberRecord(db, user.id); + if (!membership) return; - await tx - .insert(partyMember) - .values({ partyId, userId: user.id }) - .onConflictDoNothing(); - }); + if (getPayloadSize(message.payload) > MAX_MEMBER_PAYLOAD_SIZE) { + ws.send( + JSON.stringify({ + type: "error", + message: "Payload too large.", + }), + ); + return; + } - 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: membership.partyId }, + }); + if (!currentParty) return; - 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." }; - } + 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( + "/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." }; + } - if (body.memberUserId === user.id) { - set.status = 400; - return { error: "Host cannot kick themselves." }; - } + const { partyId, hostChanged, leaveResult } = await db.transaction( + async (tx) => { + const leaveResult = await leaveParty(tx, user.id); + let partyId: string | null = null; + let hostChanged = false; - 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 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, + }); + } - 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 (!partyId) { + return { + partyId: null, + hostChanged, + leaveResult, + }; + } - if (!isValidStatus(body.status)) { - set.status = 400; - return { error: "Invalid party status." }; - } + await tx + .insert(partyMember) + .values({ partyId, userId: user.id }) + .onConflictDoNothing(); - const currentData = - currentParty?.data && typeof currentParty.data === "object" - ? currentParty.data - : {}; - const nextData = body.data - ? { ...currentData, ...body.data } - : currentData; + return { + partyId, + hostChanged, + leaveResult, + }; + }, + ); - await db.transaction(async (tx) => { - await tx - .update(party) - .set({ - status: body.status, - data: nextData, - lastUpdated: new Date(), - }) - .where(eq(party.id, currentMembership.partyId)); - }); + if (!partyId) return { party: null, members: [] }; + 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: [] }; + }, + { + 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); + 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: [] }; + }, + { 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 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()), - }), - }, - ), - ); + 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); + broadcastPartyEvent(currentMembership.partyId, { + type: "member_left", + userId: body.memberUserId, + kickedBy: user.id, + }); + 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()), + }), + }, + ), + ); diff --git a/api/src/routes/stats.ts b/api/src/routes/stats.ts index e45639d..8331ea4 100644 --- a/api/src/routes/stats.ts +++ b/api/src/routes/stats.ts @@ -1,79 +1,79 @@ -import Elysia from "elysia"; import { sql } from "drizzle-orm"; +import Elysia from "elysia"; import { betterAuthElysia } from "../auth"; import { db } from "../db"; import { - artistGenre, - genre, - savedTrack, - topTrack, - trackArtist, + artistGenre, + genre, + savedTrack, + topTrack, + trackArtist, } from "../db/schema"; export const statsApp = new Elysia().use(betterAuthElysia).get( - "/stats", - async ({ user }) => { - const topArtists = await db.query.topArtist.findMany({ - limit: 10, - with: { - artist: { - with: { - genres: true, - images: true, - }, - }, - }, - where: { - userId: user.id, - }, - }); - const topTracks = await db.query.topTrack.findMany({ - limit: 10, - with: { - track: { - with: { - album: { - with: { - images: true, - }, - }, - artists: { - with: { - genres: true, - }, - }, - }, - }, - }, - where: { - userId: user.id, - timeline: "medium_term", - }, - orderBy: { - position: "desc", - }, - }); - const recentTracks = await db.query.playbackHistory.findMany({ - limit: 10, - with: { - track: { - with: { - album: { - with: { - images: true, - }, - }, - }, - }, - }, - where: { - userId: user.id, - }, - }); - const topGenresResult = await db.execute<{ - name: string; - count: number; - }>(sql` + "/stats", + async ({ user }) => { + const topArtists = await db.query.topArtist.findMany({ + limit: 10, + with: { + artist: { + with: { + genres: true, + images: true, + }, + }, + }, + where: { + userId: user.id, + }, + }); + const topTracks = await db.query.topTrack.findMany({ + limit: 10, + with: { + track: { + with: { + album: { + with: { + images: true, + }, + }, + artists: { + with: { + genres: true, + }, + }, + }, + }, + }, + where: { + userId: user.id, + timeline: "medium_term", + }, + orderBy: { + position: "desc", + }, + }); + const recentTracks = await db.query.playbackHistory.findMany({ + limit: 10, + with: { + track: { + with: { + album: { + with: { + images: true, + }, + }, + }, + }, + }, + where: { + userId: user.id, + }, + }); + const topGenresResult = await db.execute<{ + name: string; + count: number; + }>(sql` select ${genre.name} as name, count(*)::int as count from ( select distinct ${trackArtist.trackId} as track_id, ${artistGenre.genreId} as genre_id @@ -95,10 +95,10 @@ export const statsApp = new Elysia().use(betterAuthElysia).get( order by count desc limit 10 `); - const topGenres = topGenresResult.rows; - return { topArtists, topTracks, recentTracks, topGenres }; - }, - { - auth: true, - }, + const topGenres = topGenresResult.rows; + return { topArtists, topTracks, recentTracks, topGenres }; + }, + { + auth: true, + }, ); diff --git a/api/src/routes/sync.ts b/api/src/routes/sync.ts index 0f79195..3a13b9d 100644 --- a/api/src/routes/sync.ts +++ b/api/src/routes/sync.ts @@ -3,11 +3,11 @@ import { betterAuthElysia } from "../auth"; import { spotifySyncWorkflow } from "../workflows/sync"; export const syncApp = new Elysia().use(betterAuthElysia).post( - "/sync", - async ({ user }) => { - return await spotifySyncWorkflow.syncUser(user.id); - }, - { - auth: true, - }, + "/sync", + async ({ user }) => { + return await spotifySyncWorkflow.syncUser(user.id); + }, + { + auth: true, + }, ); diff --git a/api/src/workflows/sync.ts b/api/src/workflows/sync.ts index 55173fa..f6ca7d2 100644 --- a/api/src/workflows/sync.ts +++ b/api/src/workflows/sync.ts @@ -1,246 +1,247 @@ -import { DBOS, ConfiguredInstance } from "@dbos-inc/dbos-sdk"; -import { SpotifyApi } from "@spotify/web-api-ts-sdk"; +import { ConfiguredInstance, DBOS } from "@dbos-inc/dbos-sdk"; import type { - Artist, - PlayHistory, - SavedAlbum, - SavedTrack, - Track, + Artist, + PlayHistory, + SavedAlbum, + SavedTrack, + Track, } 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 { db } from "../db"; import { - followedArtist, - savedAlbum, - savedTrack, - topArtist, - topTrack, + followedArtist, + savedAlbum, + savedTrack, + topArtist, + topTrack, } from "../db/schema"; import { - upsertFollowedArtists, - upsertPlaybackHistory, - upsertSavedAlbums, - upsertSavedTracks, - upsertTopArtists, - upsertTopTracks, + upsertFollowedArtists, + upsertPlaybackHistory, + upsertSavedAlbums, + upsertSavedTracks, + upsertTopArtists, + upsertTopTracks, } from "../db/spotify"; -import { eq } from "drizzle-orm"; const timelines = ["short_term", "medium_term", "long_term"] as const; type Timeline = (typeof timelines)[number]; type SyncPayload = { - topArtistsByTimeline: Record; - topTracksByTimeline: Record; - followedArtists: Artist[]; - savedAlbums: SavedAlbum[]; - savedTracks: SavedTrack[]; - recentlyPlayed: PlayHistory[]; + topArtistsByTimeline: Record; + topTracksByTimeline: Record; + followedArtists: Artist[]; + savedAlbums: SavedAlbum[]; + savedTracks: SavedTrack[]; + recentlyPlayed: PlayHistory[]; }; export class SpotifySyncWorkflow extends ConfiguredInstance { - constructor(name: string) { - super(name); - } + @DBOS.workflow() + async syncUser(userId: string) { + console.log("Sync start"); + const data = await this.fetchSpotifyData(userId); + console.log("Sync data fetched"); + await this.persistSpotifyData(userId, data); + console.log("Synced"); + return { ok: true }; + } - @DBOS.workflow() - async syncUser(userId: string) { - console.log("Sync start"); - const data = await this.fetchSpotifyData(userId); - console.log("Sync data fetched"); - await this.persistSpotifyData(userId, data); - console.log("Synced"); - return { ok: true }; - } + private async fetchSpotifyData(userId: string): Promise { + const topArtistsByTimeline = await this.fetchTopArtists(userId); + const topTracksByTimeline = await this.fetchTopTracks(userId); + const followedArtists = await this.fetchFollowedArtists(userId); + const savedAlbums = await this.fetchSavedAlbums(userId); + const savedTracks = await this.fetchSavedTracks(userId); + const recentlyPlayed = await this.fetchRecentlyPlayed(userId); + return { + topArtistsByTimeline, + topTracksByTimeline, + followedArtists, + savedAlbums, + savedTracks, + recentlyPlayed, + }; + } - private async fetchSpotifyData(userId: string): Promise { - const topArtistsByTimeline = await this.fetchTopArtists(userId); - const topTracksByTimeline = await this.fetchTopTracks(userId); - const followedArtists = await this.fetchFollowedArtists(userId); - const savedAlbums = await this.fetchSavedAlbums(userId); - const savedTracks = await this.fetchSavedTracks(userId); - const recentlyPlayed = await this.fetchRecentlyPlayed(userId); - return { - topArtistsByTimeline, - topTracksByTimeline, - followedArtists, - savedAlbums, - savedTracks, - recentlyPlayed, - }; - } + private async persistSpotifyData(userId: string, data: SyncPayload) { + await this.persistTopArtists(userId, data.topArtistsByTimeline); + await this.persistTopTracks(userId, data.topTracksByTimeline); + await this.persistFollowedArtists(userId, data.followedArtists); + await this.persistSavedAlbums(userId, data.savedAlbums); + await this.persistSavedTracks(userId, data.savedTracks); + await this.persistPlaybackHistory(userId, data.recentlyPlayed); + } - private async persistSpotifyData(userId: string, data: SyncPayload) { - await this.persistTopArtists(userId, data.topArtistsByTimeline); - await this.persistTopTracks(userId, data.topTracksByTimeline); - await this.persistFollowedArtists(userId, data.followedArtists); - await this.persistSavedAlbums(userId, data.savedAlbums); - await this.persistSavedTracks(userId, data.savedTracks); - await this.persistPlaybackHistory(userId, data.recentlyPlayed); - } + @DBOS.step() + private async persistTopArtists( + userId: string, + topArtistsByTimeline: Record, + ) { + await db.transaction(async (tx) => { + await tx.delete(topArtist).where(eq(topArtist.userId, userId)); + for (const timeline of timelines) { + await upsertTopArtists( + userId, + timeline, + topArtistsByTimeline[timeline], + tx, + ); + } + }); + } - @DBOS.step() - private async persistTopArtists( - userId: string, - topArtistsByTimeline: Record, - ) { - await db.transaction(async (tx) => { - await tx.delete(topArtist).where(eq(topArtist.userId, userId)); - for (const timeline of timelines) { - await upsertTopArtists( - userId, - timeline, - topArtistsByTimeline[timeline], - tx, - ); - } - }); - } + @DBOS.step() + private async persistTopTracks( + userId: string, + topTracksByTimeline: Record, + ) { + await db.transaction(async (tx) => { + await tx.delete(topTrack).where(eq(topTrack.userId, userId)); + for (const timeline of timelines) { + await upsertTopTracks( + userId, + timeline, + topTracksByTimeline[timeline], + tx, + ); + } + }); + } - @DBOS.step() - private async persistTopTracks( - userId: string, - topTracksByTimeline: Record, - ) { - await db.transaction(async (tx) => { - await tx.delete(topTrack).where(eq(topTrack.userId, userId)); - for (const timeline of timelines) { - await upsertTopTracks( - userId, - timeline, - topTracksByTimeline[timeline], - tx, - ); - } - }); - } + @DBOS.step() + private async persistFollowedArtists(userId: string, artists: Artist[]) { + await db.transaction(async (tx) => { + await tx.delete(followedArtist).where(eq(followedArtist.userId, userId)); + await upsertFollowedArtists(userId, artists, tx); + }); + } - @DBOS.step() - private async persistFollowedArtists(userId: string, artists: Artist[]) { - await db.transaction(async (tx) => { - await tx.delete(followedArtist).where(eq(followedArtist.userId, userId)); - await upsertFollowedArtists(userId, artists, tx); - }); - } + @DBOS.step() + private async persistSavedAlbums(userId: string, albums: SavedAlbum[]) { + await db.transaction(async (tx) => { + await tx.delete(savedAlbum).where(eq(savedAlbum.userId, userId)); + await upsertSavedAlbums(userId, albums, tx); + }); + } - @DBOS.step() - private async persistSavedAlbums(userId: string, albums: SavedAlbum[]) { - await db.transaction(async (tx) => { - await tx.delete(savedAlbum).where(eq(savedAlbum.userId, userId)); - await upsertSavedAlbums(userId, albums, tx); - }); - } + @DBOS.step() + private async persistSavedTracks(userId: string, tracks: SavedTrack[]) { + await db.transaction(async (tx) => { + await tx.delete(savedTrack).where(eq(savedTrack.userId, userId)); + await upsertSavedTracks(userId, tracks, tx); + }); + } - @DBOS.step() - private async persistSavedTracks(userId: string, tracks: SavedTrack[]) { - await db.transaction(async (tx) => { - await tx.delete(savedTrack).where(eq(savedTrack.userId, userId)); - await upsertSavedTracks(userId, tracks, tx); - }); - } + @DBOS.step() + private async persistPlaybackHistory(userId: string, items: PlayHistory[]) { + await db.transaction(async (tx) => { + await upsertPlaybackHistory(userId, items, tx); + }); + } - @DBOS.step() - private async persistPlaybackHistory(userId: string, items: PlayHistory[]) { - await db.transaction(async (tx) => { - await upsertPlaybackHistory(userId, items, tx); - }); - } + @DBOS.step() + private async fetchTopArtists( + userId: string, + ): Promise> { + const sdk = await this.createSdk(userId); + const topArtistsByTimeline = {} as Record; + for (const timeline of timelines) { + const topArtists = await sdk.currentUser.topItems( + "artists", + timeline, + 50, + ); + topArtistsByTimeline[timeline] = topArtists.items; + } + return topArtistsByTimeline; + } - @DBOS.step() - private async fetchTopArtists( - userId: string, - ): Promise> { - const sdk = await this.createSdk(userId); - const topArtistsByTimeline = {} as Record; - for (const timeline of timelines) { - const topArtists = await sdk.currentUser.topItems( - "artists", - timeline, - 50, - ); - topArtistsByTimeline[timeline] = topArtists.items; - } - return topArtistsByTimeline; - } + @DBOS.step() + private async fetchTopTracks( + userId: string, + ): Promise> { + const sdk = await this.createSdk(userId); + const topTracksByTimeline = {} as Record; + for (const timeline of timelines) { + const topTracks = await sdk.currentUser.topItems("tracks", timeline, 50); + topTracksByTimeline[timeline] = topTracks.items; + } + return topTracksByTimeline; + } - @DBOS.step() - private async fetchTopTracks( - userId: string, - ): Promise> { - const sdk = await this.createSdk(userId); - const topTracksByTimeline = {} as Record; - for (const timeline of timelines) { - const topTracks = await sdk.currentUser.topItems("tracks", timeline, 50); - topTracksByTimeline[timeline] = topTracks.items; - } - return topTracksByTimeline; - } + @DBOS.step() + private async fetchFollowedArtists(userId: string): Promise { + const sdk = await this.createSdk(userId); + const followed: Artist[] = []; + let after: string | undefined; + while (true) { + const page = await sdk.currentUser.followedArtists(after, 50); + const artists = page.artists; + followed.push(...artists.items); + if (!artists.next || artists.items.length === 0) break; + const lastArtist = artists.items.at(-1); + after = lastArtist?.id; + } + return followed; + } - @DBOS.step() - private async fetchFollowedArtists(userId: string): Promise { - const sdk = await this.createSdk(userId); - const followed: Artist[] = []; - let after: string | undefined; - while (true) { - const page = await sdk.currentUser.followedArtists(after, 50); - const artists = page.artists; - followed.push(...artists.items); - if (!artists.next || artists.items.length === 0) break; - after = artists.items[artists.items.length - 1]!.id; - } - return followed; - } + @DBOS.step() + private async fetchSavedAlbums(userId: string): Promise { + const sdk = await this.createSdk(userId); + const saved: SavedAlbum[] = []; + let offset = 0; + while (true) { + const page = await sdk.currentUser.albums.savedAlbums(50, offset); + saved.push(...page.items); + offset += page.items.length; + if (!page.next || offset >= page.total) break; + } + return saved; + } - @DBOS.step() - private async fetchSavedAlbums(userId: string): Promise { - const sdk = await this.createSdk(userId); - const saved: SavedAlbum[] = []; - let offset = 0; - while (true) { - const page = await sdk.currentUser.albums.savedAlbums(50, offset); - saved.push(...page.items); - offset += page.items.length; - if (!page.next || offset >= page.total) break; - } - return saved; - } + @DBOS.step() + private async fetchSavedTracks(userId: string): Promise { + const sdk = await this.createSdk(userId); + const saved: SavedTrack[] = []; + let offset = 0; + while (true) { + const page = await sdk.currentUser.tracks.savedTracks(50, offset); + saved.push(...page.items); + offset += page.items.length; + if (!page.next || offset >= page.total) break; + } + return saved; + } - @DBOS.step() - private async fetchSavedTracks(userId: string): Promise { - const sdk = await this.createSdk(userId); - const saved: SavedTrack[] = []; - let offset = 0; - while (true) { - const page = await sdk.currentUser.tracks.savedTracks(50, offset); - saved.push(...page.items); - offset += page.items.length; - if (!page.next || offset >= page.total) break; - } - return saved; - } + @DBOS.step() + private async fetchRecentlyPlayed(userId: string): Promise { + const sdk = await this.createSdk(userId); + const recentlyPlayed = await sdk.player.getRecentlyPlayedTracks(50); + return recentlyPlayed.items; + } - @DBOS.step() - private async fetchRecentlyPlayed(userId: string): Promise { - const sdk = await this.createSdk(userId); - const recentlyPlayed = await sdk.player.getRecentlyPlayedTracks(50); - return recentlyPlayed.items; - } - - private async createSdk(userId: string) { - const accessToken = await auth.api.getAccessToken({ - body: { - userId, - providerId: "spotify", - }, - }); - return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, { - access_token: accessToken.accessToken, - expires_in: Date.now() - Number(accessToken.accessTokenExpiresAt!), - expires: Number(accessToken.accessTokenExpiresAt), - refresh_token: "", - token_type: "", - }); - } + private async createSdk(userId: string) { + const accessToken = await auth.api.getAccessToken({ + body: { + userId, + providerId: "spotify", + }, + }); + return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, { + access_token: accessToken.accessToken, + expires_in: accessToken.accessTokenExpiresAt + ? Date.now() - Number(accessToken.accessTokenExpiresAt) + : 0, + expires: accessToken.accessTokenExpiresAt + ? Number(accessToken.accessTokenExpiresAt) + : 0, + refresh_token: "", + token_type: "", + }); + } } export const spotifySyncWorkflow = new SpotifySyncWorkflow("spotify-sync"); diff --git a/api/tsconfig.json b/api/tsconfig.json index 0c3ab69..ada99f2 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -1,30 +1,30 @@ { - "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "experimentalDecorators": true, + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "experimentalDecorators": true, - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false, - }, + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } }