Add API and worker: accounts, follows, now-playing, cross-service links
- Fastify API with cookie (web) and bearer (mobile) sessions, follows, pinned friends, featured songs, account deletion, Redis-backed rate limits - Providers: Spotify (OAuth), Last.fm, ListenBrainz, Plex (PIN flow and server discovery), Jellyfin (token auth, revoked on disconnect) - Worker polls on an adaptive schedule in a Redis sorted set, so polling scales horizontally and API usage tracks whether anyone is watching - Track resolver links songs across services via ISRC (Deezer, Spotify), iTunes search and song.link - Realtime updates over WebSocket fan out through Redis pub/sub - User-supplied server URLs are restricted to public addresses, checked both up front and at connect time to defeat DNS rebinding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import type { NowPlayingProvider, Plan, StreamingService } from '@musicshare/shared';
|
||||
|
||||
const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
username: varchar('username', { length: 32 }).notNull().unique(),
|
||||
email: varchar('email', { length: 320 }).notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
displayName: varchar('display_name', { length: 64 }),
|
||||
bio: varchar('bio', { length: 280 }),
|
||||
avatarUrl: varchar('avatar_url', { length: 500 }),
|
||||
preferredService: text('preferred_service').$type<StreamingService>(),
|
||||
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
|
||||
plan: text('plan').$type<Plan>().notNull().default('free'),
|
||||
planExpiresAt: timestamp('plan_expires_at', { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
});
|
||||
|
||||
export const sessions = pgTable(
|
||||
'sessions',
|
||||
{
|
||||
/** sha256 of the session token; the raw token only lives in the client. */
|
||||
id: text('id').primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('sessions_user_idx').on(t.userId)],
|
||||
);
|
||||
|
||||
export const follows = pgTable(
|
||||
'follows',
|
||||
{
|
||||
followerId: uuid('follower_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
followeeId: uuid('followee_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** Set when the follower pins this friend to the top of their feed. */
|
||||
pinnedAt: timestamp('pinned_at', { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.followerId, t.followeeId] }), index('follows_followee_idx').on(t.followeeId)],
|
||||
);
|
||||
|
||||
export const connections = pgTable(
|
||||
'connections',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
provider: text('provider').$type<NowPlayingProvider>().notNull(),
|
||||
externalUserId: text('external_user_id').notNull(),
|
||||
displayName: text('display_name'),
|
||||
/** AES-256-GCM encrypted, see lib/crypto.ts */
|
||||
accessTokenEnc: text('access_token_enc'),
|
||||
refreshTokenEnc: text('refresh_token_enc'),
|
||||
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
|
||||
status: text('status').$type<'active' | 'error'>().notNull().default('active'),
|
||||
lastError: text('last_error'),
|
||||
lastPolledAt: timestamp('last_polled_at', { withTimezone: true }),
|
||||
/** Provider-specific state, e.g. Jellyfin server URL or discovered Plex servers. */
|
||||
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
|
||||
/** Secret path segment for inbound webhooks (/webhooks/:provider/:secret). */
|
||||
webhookSecret: text('webhook_secret').unique(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('connections_user_provider_idx').on(t.userId, t.provider)],
|
||||
);
|
||||
|
||||
export const tracks = pgTable(
|
||||
'tracks',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
isrc: varchar('isrc', { length: 12 }),
|
||||
/** normalized "artist|title", used to dedupe tracks that arrive without an ISRC */
|
||||
matchKey: text('match_key').notNull(),
|
||||
title: text('title').notNull(),
|
||||
artist: text('artist').notNull(),
|
||||
album: text('album'),
|
||||
durationMs: integer('duration_ms'),
|
||||
artworkUrl: text('artwork_url'),
|
||||
linksResolvedAt: timestamp('links_resolved_at', { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('tracks_isrc_idx').on(t.isrc), index('tracks_match_key_idx').on(t.matchKey)],
|
||||
);
|
||||
|
||||
export const trackLinks = pgTable(
|
||||
'track_links',
|
||||
{
|
||||
trackId: uuid('track_id')
|
||||
.notNull()
|
||||
.references(() => tracks.id, { onDelete: 'cascade' }),
|
||||
service: text('service').$type<StreamingService>().notNull(),
|
||||
externalId: text('external_id'),
|
||||
url: text('url').notNull(),
|
||||
/** how we found it: straight from the provider, via ISRC, fuzzy search, or song.link */
|
||||
matchType: text('match_type').$type<'provider' | 'isrc' | 'search' | 'odesli'>().notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.trackId, t.service] }),
|
||||
index('track_links_external_idx').on(t.service, t.externalId),
|
||||
],
|
||||
);
|
||||
|
||||
export const nowPlaying = pgTable(
|
||||
'now_playing',
|
||||
{
|
||||
userId: uuid('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
trackId: uuid('track_id')
|
||||
.notNull()
|
||||
.references(() => tracks.id),
|
||||
source: text('source').$type<NowPlayingProvider>().notNull(),
|
||||
isPlaying: boolean('is_playing').notNull(),
|
||||
progressMs: integer('progress_ms'),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('now_playing_track_idx').on(t.trackId)],
|
||||
);
|
||||
|
||||
export const featuredSongs = pgTable('featured_songs', {
|
||||
userId: uuid('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
trackId: uuid('track_id')
|
||||
.notNull()
|
||||
.references(() => tracks.id),
|
||||
comment: varchar('comment', { length: 280 }),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.default(sql`now()`),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type Connection = typeof connections.$inferSelect;
|
||||
export type Track = typeof tracks.$inferSelect;
|
||||
Reference in New Issue
Block a user