import { sql } from 'drizzle-orm'; import { boolean, customType, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid, varchar, } from 'drizzle-orm/pg-core'; import type { NowPlayingProvider, Plan, ReportReason, 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(), /** Set once they click the link we email them; null until then. */ emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }), /** A new address waiting for its confirmation link; `email` changes only once it's clicked. */ pendingEmail: varchar('pending_email', { length: 320 }), displayName: varchar('display_name', { length: 64 }), bio: varchar('bio', { length: 280 }), /** Path of the uploaded picture (`/avatars/?v=`), resolved against API_PUBLIC_URL when sent. */ avatarUrl: varchar('avatar_url', { length: 500 }), preferredService: text('preferred_service').$type(), /** The services they use, so songs only offer those; null until they choose (offer all). */ services: text('services').array().$type(), /** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */ /** The first account on a server becomes admin (moderation). */ role: text('role').$type<'user' | 'admin'>().notNull().default('user'), /** Suspended by a moderator: can't sign in, profile hidden, sharing stopped. */ suspendedAt: timestamp('suspended_at', { withTimezone: true }), /** Set when the user finishes (or skips through) onboarding; null shows it on next launch. */ onboardedAt: timestamp('onboarded_at', { withTimezone: true }), /** While in the future, nothing new is shared: services aren't polled and phone reports are dropped. */ sharingPausedUntil: timestamp('sharing_paused_until', { withTimezone: true }), plan: text('plan').$type().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)], ); /** Blocking removes follows both ways and hides each user from the other. */ export const blocks = pgTable( 'blocks', { blockerId: uuid('blocker_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), blockedId: uuid('blocked_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), createdAt: createdAt(), }, (t) => [primaryKey({ columns: [t.blockerId, t.blockedId] }), index('blocks_blocked_idx').on(t.blockedId)], ); /** User reports for moderation (App Store guideline 1.2 requires a way to report users). */ export const reports = pgTable( 'reports', { id: uuid('id').primaryKey().defaultRandom(), reporterId: uuid('reporter_id').references(() => users.id, { onDelete: 'set null' }), reportedId: uuid('reported_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), reason: text('reason').$type().notNull(), note: varchar('note', { length: 500 }), status: text('status').$type<'open' | 'resolved'>().notNull().default('open'), createdAt: createdAt(), }, (t) => [index('reports_status_idx').on(t.status)], ); export const connections = pgTable( 'connections', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), provider: text('provider').$type().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 }), /** Last time this connection reported something playing; used to spot links that silently broke. */ lastActivityAt: timestamp('last_activity_at', { withTimezone: true }), /** Provider-specific state, e.g. Jellyfin server URL or discovered Plex servers. */ meta: jsonb('meta').$type>().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().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().notNull(), /** App name when a phone reported it, e.g. "Spotify". */ sourceApp: text('source_app'), /** Where the data came from, for attribution (e.g. the user's Last.fm profile). */ sourceUrl: text('source_url'), 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)], ); /** Phones that report playback directly (Android media sessions). Each has its own revocable token. */ export const devices = pgTable( 'devices', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), /** sha256 of the device token; the token can only report playback. */ tokenHash: text('token_hash').notNull().unique(), platform: text('platform').$type<'android' | 'ios'>().notNull(), name: varchar('name', { length: 100 }).notNull(), lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), createdAt: createdAt(), }, (t) => [index('devices_user_idx').on(t.userId)], ); /** Recent plays per user, for "Recently played" on profiles. Trimmed to the latest LISTEN_HISTORY_LIMIT. */ export const listens = pgTable( 'listens', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), trackId: uuid('track_id') .notNull() .references(() => tracks.id), source: text('source').$type().notNull(), startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => [index('listens_user_started_idx').on(t.userId, t.startedAt.desc())], ); 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()`), }); /** One emoji per person per play. Deleted with the play (only the latest 50 plays are kept). */ export const reactions = pgTable( 'reactions', { listenId: uuid('listen_id') .notNull() .references(() => listens.id, { onDelete: 'cascade' }), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), emoji: varchar('emoji', { length: 32 }).notNull(), createdAt: createdAt(), }, (t) => [primaryKey({ columns: [t.listenId, t.userId] })], ); /** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */ export const emailVerifications = pgTable( 'email_verifications', { id: text('id').primaryKey(), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), email: varchar('email', { length: 320 }).notNull(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), createdAt: createdAt(), }, (t) => [index('email_verifications_user_idx').on(t.userId)], ); /** One-time password reset links. Only a hash of the token is stored. */ export const passwordResets = pgTable( 'password_resets', { id: text('id').primaryKey(), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), usedAt: timestamp('used_at', { withTimezone: true }), createdAt: createdAt(), }, (t) => [index('password_resets_user_idx').on(t.userId)], ); /** Lyrics looked up per track (misses too, so we don't keep asking). */ export const lyrics = pgTable('lyrics', { trackId: uuid('track_id') .primaryKey() .references(() => tracks.id, { onDelete: 'cascade' }), /** LRC text with timestamps. */ synced: text('synced'), plain: text('plain'), instrumental: boolean('instrumental').notNull().default(false), /** Where they came from (the LRCLIB record), null when none were found. */ sourceUrl: text('source_url'), fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(), }); const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' }); /** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */ export const avatars = pgTable('avatars', { userId: uuid('user_id') .primaryKey() .references(() => users.id, { onDelete: 'cascade' }), image: bytea('image').notNull(), contentType: text('content_type').notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); export type User = typeof users.$inferSelect; export type Connection = typeof connections.$inferSelect; export type Track = typeof tracks.$inferSelect;