Files
musicshare/apps/api/src/db/schema.ts
T
chrisandClaude Opus 5.5 d2edad4b0d
CI / check (push) Canceled after 0s
Emoji reactions on songs
React to a friend's current song or any of their recent plays: tap an
emoji to add yours (or take it back), "☺+" for the quick six, and a full
picker with search and categories (Unicode emoji up to 13.1, generated
into the shared package so web and mobile match). One reaction per person
per play; a new one replaces the old. Chips show counts and, on hover/
long label, who reacted; yours is highlighted. Listeners and followers get
the update live. Not on your own plays, not across a block (blocking also
clears reactions between the two people).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-06 13:39:26 -04:00

306 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>(),
/** 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)],
);
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;