API: follower lists, blocking, reports, admin; drop Spotify sign-in

- Followers/following lists (paginated), 'follows you', remove a follower
- Blocking severs follows both ways and hides each user from the other
  (profile 404s, search, follows); the blocker sees activity hidden
- Reports for moderation (App Store guideline 1.2)
- First account on a server becomes admin; admins see the report queue,
  resolve reports, and suspend accounts (signs out, blocks login, hides
  the profile, stops sharing). set-role script to promote/demote
- users.onboardedAt drives first-run onboarding; existing accounts are
  backfilled as already onboarded. Empty PATCH /me no longer 500s
- Remove Spotify account sign-in and polling: Spotify caps new apps at 5
  accounts, so Spotify arrives via its Last.fm/ListenBrainz link or the
  Android reader. App-level catalog search stays for exact links
- Shared: QR code generation and profile-link parsing (verified with jsQR)
- Integration test timeout raised; password hashing is slow by design

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-05 19:16:40 -04:00
co-authored by Claude Opus 5.5
parent 63d618f0e3
commit 586acdbeab
44 changed files with 4559 additions and 220 deletions
+39 -1
View File
@@ -12,7 +12,7 @@ import {
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import type { NowPlayingProvider, Plan, StreamingService } from '@musicshare/shared';
import type { NowPlayingProvider, Plan, ReportReason, StreamingService } from '@musicshare/shared';
const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
@@ -26,6 +26,12 @@ export const users = pgTable('users', {
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'),
@@ -63,6 +69,38 @@ export const follows = pgTable(
(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',
{