Files
musicshare/apps/api/src/db/schema.ts
T
chrisandClaude Opus 5.5 68ec5a8d99 Slim song cards with an expand arrow, and lyrics
Feed and profile cards now show one slim line (small artwork, title,
artist, Open button) with the progress bar and reactions. The arrow
opens the rest: album, your other services, Share, and lyrics.

Lyrics come from LRCLIB (open source; LRCLIB_URL can point at a
self-hosted copy, or be empty to turn lyrics off). The server looks each
song up once and caches it, including misses (retried after a week).
Timed lyrics follow along with the song; otherwise the first lines show
with "All lyrics".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:19:56 -04:00

322 lines
12 KiB
TypeScript

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/<id>?v=<version>`), resolved against API_PUBLIC_URL when sent. */
avatarUrl: varchar('avatar_url', { length: 500 }),
preferredService: text('preferred_service').$type<StreamingService>(),
/** The services they use, so songs only offer those; null until they choose (offer all). */
services: text('services').array().$type<StreamingService[]>(),
/** 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<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)],
);
/** 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<ReportReason>().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<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 }),
/** 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<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(),
/** 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<NowPlayingProvider>().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;