diff --git a/.env.example b/.env.example index 3026e3e..0b8d075 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,11 @@ NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 # Database (optional, defaults to ./data/swapify.db) # DATABASE_PATH=./data/swapify.db + +# AI — vibe name generation (optional, get key at console.anthropic.com) +ANTHROPIC_API_KEY= + +# Spotify dev mode (optional, default: false) +# When true: limits to 5 users, conservative API rate limits, longer poll intervals. +# Use this for Spotify apps in development mode (not yet approved for extended quota). +SPOTIFY_DEV_MODE=true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1adf76f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + labels: + - "dependencies" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c168bd3..9add68d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [main] pull_request: branches: [main] @@ -34,6 +32,4 @@ jobs: - name: Build run: npm run build env: - SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }} - SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - NEXT_PUBLIC_BASE_URL: https://swapify.312.dev + NEXT_PUBLIC_APP_URL: https://swapify.312.dev diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1112f6d..05c5c72 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,8 +6,57 @@ on: workflow_dispatch: jobs: + ci: + name: Lint, Type Check, Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Type check + run: npm run type-check + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_APP_URL: https://swapify.312.dev + + migrate: + name: Run Database Migrations + needs: ci + runs-on: ubuntu-latest + concurrency: deploy-production + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run migrations + run: npm run db:migrate + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + deploy: name: Deploy to Fly.io + needs: migrate runs-on: ubuntu-latest concurrency: deploy-production diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6a84426..ed20edd 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -128,8 +128,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Run npm audit - run: npm audit --audit-level=moderate + - name: Run npm audit (production dependencies) + run: npm audit --audit-level=moderate --omit=dev # ============================================================================ # Security Status - Aggregates all security job results diff --git a/.gitignore b/.gitignore index c24fb60..36b4bdb 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,9 @@ next-env.d.ts # local database /data/ + +# color palette previews +/color-previews/ + +# hero background videos (too large for git) +/public/videos/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bfe2989 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,162 @@ +# Swapify + +Spotify collaborative playlist app. Users create shared playlists ("Swaplists"), add tracks, and react to each other's picks via swipe gestures. + +**Rebrand history**: JamJar -> Deep Digs -> **Swapify**. Playlists are called **"Swaplists"** in the UI. + +## Tech Stack + +- **Framework**: Next.js 16 App Router + TypeScript + React 19 +- **Styling**: Tailwind CSS v4 + shadcn/ui (new-york style, neutral base, dark-only theme) +- **Database**: Drizzle ORM + PostgreSQL (`pgTable` from `drizzle-orm/pg-core`) +- **Auth**: Manual Spotify OAuth PKCE flow (no NextAuth) + iron-session +- **Animations**: Motion (Framer Motion v11+) — import from `"motion/react"` NOT `"framer-motion"` +- **Deployment**: Fly.io (standalone output, Docker) +- **Notifications**: Web push (web-push) + email (Resend) + +## Database: Dual Driver Setup + +The app uses **PGlite** (embedded Postgres, file-based) for local dev and **node-postgres** for production. Both speak native Postgres SQL, so migrations work identically on both. + +- `DATABASE_URL` env var present -> production Postgres (node-postgres) +- `DATABASE_URL` absent -> PGlite at `./data/swapify-pg` (or `DATABASE_PATH`) +- Driver selection in [src/db/index.ts](src/db/index.ts) uses `require()` for dynamic loading +- Type safety via `import type` from node-postgres (erased at compile time, avoids bundling) +- **`serverExternalPackages`** in [next.config.ts](next.config.ts) prevents Turbopack from bundling PGlite/pg/pino + +### Migration Pipeline + +```bash +npm run db:generate # Generate SQL from schema changes (drizzle-kit generate) +npm run db:migrate # Run migrations (src/db/migrate.ts — dual-driver aware) +npm run db:seed # Seed data (needs rewrite — still uses raw better-sqlite3) +``` + +Migrations live in `drizzle/*.sql`. Archived SQLite migrations in `drizzle-sqlite-archive/`. + +**IMPORTANT**: If you encounter "column X does not exist" or similar schema errors, always run `npm run db:migrate` first — the schema likely has pending migrations that haven't been applied to the local PGlite database. + +### Schema Tables + +`users`, `playlists`, `playlist_members`, `playlist_tracks`, `track_listens`, `track_reactions`, `email_invites`, `push_subscriptions` + +Schema exports use generic camelCase naming: `playlists`, `playlistMembers`, `playlistTracks`, etc. + +## Project Structure + +### Pages (App Router) + +| Route | Description | +|---|---| +| `/` | Landing / login redirect | +| `/login` | Spotify OAuth login | +| `/dashboard` | Swaplist list + Create/Join bottom sheets | +| `/playlist/[playlistId]` | Playlist detail with swipeable track cards | +| `/playlist/[playlistId]/settings` | Playlist settings (owner only) | +| `/playlist/join` | Deep link join flow | +| `/activity` | Activity feed | +| `/profile` | User profile + notification preferences | + +### Key Components + +| Component | Purpose | +|---|---| +| `LayoutShell` | Wraps all pages: LazyMotion + BottomNav | +| `BottomNav` | 3 tabs: Swaplists (/dashboard), Activity (/activity), Profile (/profile) | +| `SwipeableTrackCard` | Swipe-right = thumbs_up, swipe-left = thumbs_down | +| `GlassDrawer` | Slide-up panel (shadcn Drawer/Vaul) with drag-to-dismiss | +| `PlaylistCard` | Dashboard playlist card | +| `ReactionOverlay` | Emoji reaction picker overlay | +| `TrackSearch` | Spotify track search for adding to playlists | +| `ShareSheet` | Share/invite bottom sheet | +| `PlaylistTabs` | Tab navigation within playlist detail (Active, Liked, Outcasts, History) | + +### Key Libraries + +| File | Purpose | +|---|---| +| `src/lib/spotify.ts` | All Spotify API calls (uses owner's token for playlist mutations) | +| `src/lib/polling.ts` | Listen detection via Spotify playback polling | +| `src/lib/auth.ts` | `getSession()`, `getCurrentUser()`, `requireAuth()` | +| `src/lib/session.ts` | iron-session config (cookie: `swapify_session`, 30-day expiry) | +| `src/lib/spotify-config.ts` | Dev mode config, global API call budget tracker | +| `src/lib/rate-limit.ts` | In-memory token-bucket rate limiter (dev-mode-aware) | +| `src/lib/crypto.ts` | AES-256-GCM token encryption (optional via `TOKEN_ENCRYPTION_KEY`) | +| `src/lib/notifications.ts` | Push + email notification dispatch | +| `src/lib/logger.ts` | Pino structured logging | +| `src/lib/motion.ts` | Motion presets: `springs.snappy/smooth/gentle`, `fade`, `STAGGER_DELAY` | +| `src/lib/utils.ts` | `cn()` (clsx + tailwind-merge), `generateId()`, `formatPlaylistName()` | +| `src/lib/vibe-sort.ts` | Auto-sort playlist tracks by audio features | +| `src/env.ts` | Zod env validation (lazy proxy — validates on first access, safe at build) | + +## Environment Variables + +### Required +- `SPOTIFY_CLIENT_ID` — Spotify app client ID +- `SPOTIFY_REDIRECT_URI` — OAuth callback URL +- `IRON_SESSION_PASSWORD` — Min 32 chars, session encryption key +- `POLL_SECRET` — Min 16 chars, polling endpoint auth +- `NEXT_PUBLIC_APP_URL` — Full app URL (e.g., `https://swapify.312.dev`) + +### Optional +- `DATABASE_URL` — Production Postgres connection string (absent = PGlite local) +- `DATABASE_PATH` — Override PGlite data directory (default: `./data/swapify-pg`) +- `TOKEN_ENCRYPTION_KEY` — 32-byte base64 key for Spotify token encryption at rest +- `RESEND_API_KEY` — Email sending via Resend +- `NEXT_PUBLIC_VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` — Web push +- `POLL_INTERVAL_MS` — Polling interval (default: 30000, dev mode default: 60000) +- `SPOTIFY_DEV_MODE` — Set to `true` for Spotify dev mode (max 5 users, conservative rate limits, longer poll intervals) + +## Key Patterns + +- **Spotify mutations always use the playlist owner's token** — non-owners get 403 from Spotify +- **Polling** runs via `instrumentation.ts` setInterval (30s default) calling `/api/poll` +- **Dashboard** has Create/Join bottom sheets (not separate pages); `/playlist/new` redirects to `/dashboard` +- **Playlist naming**: `formatPlaylistName()` — initials for <=3 members, group name for >3, suffix "Swapify" +- **No `alert()` calls** — all replaced with `toast.error()` / `toast.info()` (Sonner) +- **Dark-only theme** — shadcn CSS vars mapped directly in `:root` (no `.dark` class toggle) +- **Color palette**: "Arctic Aurora" — `--brand: #38BDF8` (sky blue primary), `--brand-hover: #7DD3FC`, `--accent-green: #4ADE80` (aurora green accent). Tailwind classes: `text-brand`, `bg-brand`, etc. Gradient start: `#081420` (deep navy) +- **Design tokens**: glassmorphism (`glass` class), gradients (`gradient-bg`, `gradient-bg-radial`), `input-glass`, `btn-pill btn-pill-primary/secondary` + +## Spotify Dev Mode (`SPOTIFY_DEV_MODE`) + +Set `SPOTIFY_DEV_MODE=true` for Spotify apps in development mode (max 5 users). This activates: +- **5-user cap** enforced at OAuth callback (new signups rejected after limit) +- **Global API call budget**: 50 calls/30s (vs 300 in production), tracked in `src/lib/spotify-config.ts` +- **Longer poll interval**: 60s (vs 30s), reduced audit/sync frequencies +- **Stricter per-user rate limits**: search 10/min, mutations 8/min, API 20/min +- **Search limit**: 5 results per query (vs 10) + +Config lives in `src/lib/spotify-config.ts`. All values are getters that read `process.env.SPOTIFY_DEV_MODE` at call time. + +## Security + +- **Rate limiting**: In-memory token-bucket with profiles (api, search, mutation, invite, public) — all dev-mode-aware +- **Spotify API budget**: Rolling 30s window call tracker prevents hitting Spotify's per-app rate limit +- **Token encryption**: AES-256-GCM, backward-compatible with plaintext (no-op if key unset) +- **Security headers**: CSP, HSTS, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy +- **Session**: iron-session, `sameSite: 'lax'`, `httpOnly: true`, `secure` in production +- **Env validation**: Zod schema with lazy proxy (validates on first property access) +- **Structured logging**: Pino (JSON in production, debug in dev) + +## Build & Deploy + +```bash +npm run dev # Local dev server (PGlite) +npm run build # Production build +npm run start # Start production server +npm run lint # ESLint +npm run type-check # TypeScript check +``` + +**PGlite build noise**: PGlite emits ENOENT errors during `next build` — these are non-fatal (pages still generate correctly). + +**Deployment**: Fly.io with standalone Docker output. Health check at `/api/health`. Set secrets via `fly secrets set DATABASE_URL=...`. + +## Remaining Production Tasks + +See [PRODUCTION_CHECKLIST.md](PRODUCTION_CHECKLIST.md) for full status. Outstanding items: +- Rotate all secrets for production +- Choose Postgres provider (Neon / Supabase / Fly Postgres), provision DB, run migrations +- Rewrite `seed.ts` to use Drizzle ORM (still uses raw better-sqlite3) +- Configure database backups diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..afe943d --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,190 @@ +# Swapify Deployment + +## Prerequisites + +Install these CLIs before running the setup script: + +```bash +# Fly.io CLI +curl -L https://fly.io/install.sh | sh +flyctl auth login + +# Neon CLI (for managed Postgres) +npm i -g neonctl +neonctl auth + +# GitHub CLI (for setting repo secrets) +brew install gh +gh auth login +``` + +## Quick Start (Automated) + +The setup script handles: secret generation, Neon DB provisioning, Fly app creation, Fly + GitHub secrets, migrations, and first deploy. + +```bash +./scripts/deploy-init.sh --spotify-client-id +``` + +With optional services: +```bash +./scripts/deploy-init.sh \ + --spotify-client-id \ + --resend-api-key re_... \ + --anthropic-api-key sk-ant-... +``` + +Preview what it would do: +```bash +./scripts/deploy-init.sh --spotify-client-id --dry-run +``` + +### What the script does + +1. Generates `IRON_SESSION_PASSWORD`, `POLL_SECRET`, `TOKEN_ENCRYPTION_KEY`, VAPID keys +2. Creates a Neon Postgres project (`us-east-2`, closest to Fly `ord`) +3. Creates the Fly.io app +4. Sets all secrets on Fly.io +5. Creates a `FLY_API_TOKEN` deploy token and sets it + `DATABASE_URL` as GitHub repo secrets +6. Runs `npm run db:migrate` against the production DB +7. Deploys to Fly.io +8. Verifies the health check + +--- + +## Manual Steps (cannot be automated) + +### 1. Spotify Developer Dashboard + +- [ ] Go to [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) +- [ ] Create a new app (or use existing) — set the app name to "Swapify" +- [ ] Under **Settings > Redirect URIs**, add: `https://swapify.312.dev/api/auth/callback` +- [ ] Under **Settings > APIs used**, ensure **Web API** and **Web Playback SDK** are checked +- [ ] Add beta tester emails under **User Management** (max 5 in dev mode) +- [ ] Set `SPOTIFY_DEV_MODE=true` on Fly (see [Spotify Dev Mode](#spotify-dev-mode) below) +- [ ] To go public, submit a **Quota Extension Request** and set `SPOTIFY_DEV_MODE=false` + +### 2. DNS + +- [ ] Add a CNAME record: `swapify.312.dev` -> `swapify.fly.dev` +- [ ] After DNS propagates, provision TLS: `flyctl certs add swapify.312.dev` + +### 3. Post-Deploy Smoke Test + +- [ ] Visit `https://swapify.312.dev` — landing page loads +- [ ] Login with Spotify — redirects through OAuth and lands on `/dashboard` +- [ ] Create a Swaplist — Spotify playlist created +- [ ] Share invite link — second user can join +- [ ] Add a track — appears in Spotify playlist and in app +- [ ] Swipe to react — reaction saved +- [ ] `curl https://swapify.312.dev/api/health` returns `{"status":"ok"}` + +--- + +## CI/CD Pipeline + +Deploys happen automatically via GitHub Actions on push to `main`: + +1. **CI** — lint, type-check, build +2. **Migrate** — runs `npm run db:migrate` against production (uses `DATABASE_URL` secret) +3. **Deploy** — `flyctl deploy --remote-only` (uses `FLY_API_TOKEN` secret) + +### GitHub Secrets Required + +| Secret | Set by script | Purpose | +|---|---|---| +| `FLY_API_TOKEN` | Yes | Fly.io deploy token | +| `DATABASE_URL` | Yes | Production Postgres connection string | + +### Workflow files + +- [.github/workflows/ci.yml](.github/workflows/ci.yml) — PR checks (lint, type-check, build) +- [.github/workflows/deploy.yml](.github/workflows/deploy.yml) — Push to main (CI + migrate + deploy) +- [.github/workflows/security.yml](.github/workflows/security.yml) — CodeQL, Gitleaks, npm audit + +--- + +## Infrastructure + +| Component | Service | Cost | +|---|---|---| +| App server | Fly.io shared-cpu-1x, 512MB, always-on | ~$3.32/mo | +| Database | Neon free tier (0.5GB, auto-suspend) | $0 | +| **Total** | | **~$3.32/mo** | + +The machine runs 24/7 (`min_machines_running = 1`) because the polling loop needs to stay alive to detect Spotify listens and refresh tokens. + +--- + +## Schema Changes + +After modifying `src/db/schema.ts`: + +```bash +npm run db:generate # Generate new migration SQL in drizzle/ +git add drizzle/ && git commit # Commit the migration +git push # Triggers: CI → migrate → deploy +``` + +Migrations run automatically in the deploy pipeline before the new code is deployed. + +--- + +## Fly.io Secrets Reference + +| Secret | Required | Generated by script | +|---|---|---| +| `SPOTIFY_CLIENT_ID` | Yes | No (you provide it) | +| `SPOTIFY_REDIRECT_URI` | Yes | Yes | +| `IRON_SESSION_PASSWORD` | Yes | Yes | +| `POLL_SECRET` | Yes | Yes | +| `TOKEN_ENCRYPTION_KEY` | Yes | Yes | +| `DATABASE_URL` | Yes | Yes (from Neon) | +| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Yes | Yes | +| `VAPID_PRIVATE_KEY` | Yes | Yes | +| `VAPID_SUBJECT` | Yes | Yes | +| `RESEND_API_KEY` | No | No (you provide it) | +| `ANTHROPIC_API_KEY` | No | No (you provide it) | +| `SPOTIFY_DEV_MODE` | No | No (default: `false`) | + +--- + +## Spotify Dev Mode + +Set `SPOTIFY_DEV_MODE=true` when your Spotify app is still in **development mode** (not yet approved for extended quota). This activates conservative rate limiting to stay within Spotify's lower API budget for dev apps. + +```bash +fly secrets set SPOTIFY_DEV_MODE=true +``` + +### What dev mode does + +| Setting | Dev Mode (`true`) | Production (`false`) | +|---|---|---| +| Max authenticated users | **5** (Spotify's dev mode limit) | Unlimited | +| API call budget (rolling 30s) | **50 calls** | 300 calls | +| Poll interval | **60s** | 30s | +| Search results per query | **5** | 10 | +| Saved-tracks check | Every **10th** poll cycle | Every 4th cycle | +| Playlist audit | Every **6th** poll cycle | Every 2nd cycle | +| Liked playlist sync | Every **10th** poll cycle | Every 4th cycle | +| Per-user search rate limit | **10 req/min** | 30 req/min | +| Per-user mutation rate limit | **8 req/min** | 20 req/min | +| Per-user general API limit | **20 req/min** | 60 req/min | + +### When to disable dev mode + +Set `SPOTIFY_DEV_MODE=false` (or remove it) after your Spotify app is approved for **extended quota mode**. Requirements for extended quota (as of May 2025): +- Legally registered business entity +- 250,000+ monthly active users +- Available in key Spotify markets +- Active, launched service + +Until then, keep dev mode enabled to avoid hitting Spotify's rate limits. + +### How it works + +- **Global API call budget** ([src/lib/spotify-config.ts](src/lib/spotify-config.ts)): Tracks all Spotify API calls in a rolling 30-second window. Every `spotifyFetch` call checks the budget before proceeding. If the budget is exhausted, calls wait up to 10 seconds for capacity. +- **User cap enforcement**: New user signups are rejected at the OAuth callback when the user count reaches the limit. Existing users can still log in. +- **Polling throttling**: Background operations (audit, saved-tracks check, liked sync) run at reduced frequencies. The polling loop aborts early if the API budget is exhausted. +- **Per-user rate limits** ([src/lib/rate-limit.ts](src/lib/rate-limit.ts)): Token-bucket limits on user-facing routes (search, mutations) are halved in dev mode. diff --git a/Dockerfile b/Dockerfile index 86b9b3f..244c574 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,9 +31,6 @@ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -# Create data directory for SQLite -RUN mkdir -p /data && chown nextjs:nodejs /data - USER nextjs EXPOSE 3000 ENV PORT=3000 diff --git a/PRODUCTION_CHECKLIST.md b/PRODUCTION_CHECKLIST.md new file mode 100644 index 0000000..17fad24 --- /dev/null +++ b/PRODUCTION_CHECKLIST.md @@ -0,0 +1,92 @@ +# Swapify Production Readiness Checklist + +## P0 — Must Fix Before Deploy + +- [ ] **Rotate all secrets** + - [ ] Generate strong `IRON_SESSION_PASSWORD` (`openssl rand -base64 32`) + - [ ] Generate strong `POLL_SECRET` + - [ ] Generate `TOKEN_ENCRYPTION_KEY` (`node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`) + - [ ] Rotate `SPOTIFY_CLIENT_ID` if `.env.local` was ever committed + +- [x] **Migrate SQLite → PostgreSQL** + - [x] Dual driver: PGlite (local dev) / node-postgres (production) + - [x] Schema rewritten with `pgTable`, proper `boolean` and `timestamp` types + - [x] `src/db/index.ts` — driver selected by `DATABASE_URL` env var + - [x] Initial Postgres migration generated (`drizzle/0000_mean_selene.sql`) + - [x] `drizzle.config.ts` updated to `postgresql` dialect + - [x] Dockerfile updated (removed SQLite data directory) + - [x] `fly.toml` updated (removed volume mount, `DATABASE_URL` via fly secrets) + - [x] Migration runner: `npm run db:migrate` (`src/db/migrate.ts`) + - [ ] Choose provider (Neon / Supabase / Fly Postgres) + - [ ] Provision production database and set `DATABASE_URL` + - [ ] Run `npm run db:migrate` against production + - [ ] Rewrite `seed.ts` to use Drizzle ORM (currently still uses raw better-sqlite3) + +- [x] **Add security headers in `next.config.ts`** + - [x] `Content-Security-Policy` + - [x] `X-Frame-Options: DENY` + - [x] `X-Content-Type-Options: nosniff` + - [x] `Strict-Transport-Security` + - [x] `Referrer-Policy: strict-origin-when-cross-origin` + - [x] `Permissions-Policy` (disable camera, mic, geolocation) + +- [x] **Add env var validation** + - [x] Create `src/env.ts` with zod schema (lazy proxy — safe at build time) + - [x] Validate all required vars on first access + - [x] Fail fast with clear error messages if missing + +## P1 — Fix Soon After Deploy + +- [x] **API rate limiting** + - [x] In-memory token-bucket rate limiter (`src/lib/rate-limit.ts`) + - [x] Playlist creation: `mutation` profile (20 req/min per user, 8 in dev mode) + - [x] Track additions: `mutation` profile (20 req/min per user, 8 in dev mode) + - [x] Email invites: `invite` profile (10/hr per user) + - [x] Search: `search` profile (30 req/min per user, 10 in dev mode) + - [x] Invite code resolution: `public` profile (20 req/min per IP) + - [x] Global Spotify API call budget: rolling 30s window (50 calls dev / 300 production) + - [x] `SPOTIFY_DEV_MODE` env var: enforces 5-user cap, conservative rate limits, longer polling + +- [x] **Encrypt Spotify tokens at rest** + - [x] AES-256-GCM encrypt/decrypt utility (`src/lib/crypto.ts`) + - [x] `TOKEN_ENCRYPTION_KEY` env var (optional — plaintext if unset for local dev) + - [x] Encrypt tokens before DB write in auth callback + - [x] Decrypt tokens on read in `spotify.ts` + - [x] Backward-compatible: existing plaintext tokens decrypt transparently + +- [x] **Health check endpoint** + - [x] Create `src/app/api/health/route.ts` + - [x] Verify DB connectivity in handler + - [x] Return `{ status: "ok" }` or `503` + - [x] Wire up in `fly.toml` http checks + +- [x] **Sanitize error responses** + - [x] Audit all API routes for leaked error details + - [x] Replace Spotify error passthrough with generic messages + - [x] Log full errors server-side, return safe messages to client + +## P2 — Harden + +- [x] **Structured logging** + - [x] Pino logger (`src/lib/logger.ts`) + - [x] Replaced `console.*` in polling.ts, auth callback, health check + - [x] JSON output in production, debug level in dev + +- [x] **CSRF protection** + - [x] Evaluated: `sameSite: 'lax'` + `httpOnly` cookies are sufficient + - [x] All mutations use `fetch()` (not form submissions) — no cross-origin POST risk + - [x] Token-based CSRF not needed given architecture (can add later if needed) + +- [x] **Enable Dependabot** + - [x] `.github/dependabot.yml` — weekly npm + GitHub Actions updates + - [x] Minor/patch grouped, 10 PR limit + +- [ ] **AI vibe name generation** + - [ ] Create Anthropic API key at [console.anthropic.com](https://console.anthropic.com) + - [ ] `fly secrets set ANTHROPIC_API_KEY=sk-ant-...` + - [ ] Uses Claude Haiku (~$0.0001/generation) — generates Daylist-style labels for playlists with >3 tracks + +- [ ] **Database backups** + - [ ] Verify managed Postgres provider has automated backups + - [ ] Configure backup retention policy + - [ ] Test restore procedure diff --git a/drizzle/0000_robust_overlord.sql b/drizzle-sqlite-archive/0000_robust_overlord.sql similarity index 100% rename from drizzle/0000_robust_overlord.sql rename to drizzle-sqlite-archive/0000_robust_overlord.sql diff --git a/drizzle/0001_slimy_hedge_knight.sql b/drizzle-sqlite-archive/0001_slimy_hedge_knight.sql similarity index 100% rename from drizzle/0001_slimy_hedge_knight.sql rename to drizzle-sqlite-archive/0001_slimy_hedge_knight.sql diff --git a/drizzle/0002_colorful_gorgon.sql b/drizzle-sqlite-archive/0002_colorful_gorgon.sql similarity index 100% rename from drizzle/0002_colorful_gorgon.sql rename to drizzle-sqlite-archive/0002_colorful_gorgon.sql diff --git a/drizzle/0003_fresh_jetstream.sql b/drizzle-sqlite-archive/0003_fresh_jetstream.sql similarity index 100% rename from drizzle/0003_fresh_jetstream.sql rename to drizzle-sqlite-archive/0003_fresh_jetstream.sql diff --git a/drizzle/0004_mighty_black_widow.sql b/drizzle-sqlite-archive/0004_mighty_black_widow.sql similarity index 100% rename from drizzle/0004_mighty_black_widow.sql rename to drizzle-sqlite-archive/0004_mighty_black_widow.sql diff --git a/drizzle/0005_violet_mercury.sql b/drizzle-sqlite-archive/0005_violet_mercury.sql similarity index 100% rename from drizzle/0005_violet_mercury.sql rename to drizzle-sqlite-archive/0005_violet_mercury.sql diff --git a/drizzle/0006_rename_jam_to_playlist.sql b/drizzle-sqlite-archive/0006_rename_jam_to_playlist.sql similarity index 100% rename from drizzle/0006_rename_jam_to_playlist.sql rename to drizzle-sqlite-archive/0006_rename_jam_to_playlist.sql diff --git a/drizzle/0007_add_removal_delay.sql b/drizzle-sqlite-archive/0007_add_removal_delay.sql similarity index 100% rename from drizzle/0007_add_removal_delay.sql rename to drizzle-sqlite-archive/0007_add_removal_delay.sql diff --git a/drizzle-sqlite-archive/0008_add_liked_playlist_id.sql b/drizzle-sqlite-archive/0008_add_liked_playlist_id.sql new file mode 100644 index 0000000..8a5a0db --- /dev/null +++ b/drizzle-sqlite-archive/0008_add_liked_playlist_id.sql @@ -0,0 +1 @@ +ALTER TABLE `playlist_members` ADD `liked_playlist_id` text; diff --git a/drizzle-sqlite-archive/meta/0000_snapshot.json b/drizzle-sqlite-archive/meta/0000_snapshot.json new file mode 100644 index 0000000..a5b04b1 --- /dev/null +++ b/drizzle-sqlite-archive/meta/0000_snapshot.json @@ -0,0 +1,683 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c774036a-b1d3-490e-9930-91ec7fc76e5d", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "jam_members": { + "name": "jam_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "jam_id": { + "name": "jam_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "jam_members_jam_user_idx": { + "name": "jam_members_jam_user_idx", + "columns": [ + "jam_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "jam_members_jam_id_jams_id_fk": { + "name": "jam_members_jam_id_jams_id_fk", + "tableFrom": "jam_members", + "tableTo": "jams", + "columnsFrom": [ + "jam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "jam_members_user_id_users_id_fk": { + "name": "jam_members_user_id_users_id_fk", + "tableFrom": "jam_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jam_tracks": { + "name": "jam_tracks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "jam_id": { + "name": "jam_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spotify_track_uri": { + "name": "spotify_track_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spotify_track_id": { + "name": "spotify_track_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_name": { + "name": "track_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artist_name": { + "name": "artist_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "album_name": { + "name": "album_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "album_image_url": { + "name": "album_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_by_user_id": { + "name": "added_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "jam_tracks_jam_uri_idx": { + "name": "jam_tracks_jam_uri_idx", + "columns": [ + "jam_id", + "spotify_track_uri" + ], + "isUnique": true + } + }, + "foreignKeys": { + "jam_tracks_jam_id_jams_id_fk": { + "name": "jam_tracks_jam_id_jams_id_fk", + "tableFrom": "jam_tracks", + "tableTo": "jams", + "columnsFrom": [ + "jam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "jam_tracks_added_by_user_id_users_id_fk": { + "name": "jam_tracks_added_by_user_id_users_id_fk", + "tableFrom": "jam_tracks", + "tableTo": "users", + "columnsFrom": [ + "added_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jams": { + "name": "jams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spotify_playlist_id": { + "name": "spotify_playlist_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invite_code": { + "name": "invite_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "jams_invite_code_unique": { + "name": "jams_invite_code_unique", + "columns": [ + "invite_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "jams_owner_id_users_id_fk": { + "name": "jams_owner_id_users_id_fk", + "tableFrom": "jams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sub_user_endpoint_idx": { + "name": "push_sub_user_endpoint_idx", + "columns": [ + "user_id", + "endpoint" + ], + "isUnique": true + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "track_listens": { + "name": "track_listens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "jam_id": { + "name": "jam_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spotify_track_id": { + "name": "spotify_track_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listened_at": { + "name": "listened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "track_listens_jam_track_user_idx": { + "name": "track_listens_jam_track_user_idx", + "columns": [ + "jam_id", + "spotify_track_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "track_listens_jam_id_jams_id_fk": { + "name": "track_listens_jam_id_jams_id_fk", + "tableFrom": "track_listens", + "tableTo": "jams", + "columnsFrom": [ + "jam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "track_listens_user_id_users_id_fk": { + "name": "track_listens_user_id_users_id_fk", + "tableFrom": "track_listens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "track_reactions": { + "name": "track_reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "jam_id": { + "name": "jam_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spotify_track_id": { + "name": "spotify_track_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_auto": { + "name": "is_auto", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "track_reactions_jam_track_user_idx": { + "name": "track_reactions_jam_track_user_idx", + "columns": [ + "jam_id", + "spotify_track_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "track_reactions_jam_id_jams_id_fk": { + "name": "track_reactions_jam_id_jams_id_fk", + "tableFrom": "track_reactions", + "tableTo": "jams", + "columnsFrom": [ + "jam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "track_reactions_user_id_users_id_fk": { + "name": "track_reactions_user_id_users_id_fk", + "tableFrom": "track_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spotify_id": { + "name": "spotify_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notify_push": { + "name": "notify_push", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "notify_email": { + "name": "notify_email", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "auto_negative_reactions": { + "name": "auto_negative_reactions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "recent_emojis": { + "name": "recent_emojis", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_poll_cursor": { + "name": "last_poll_cursor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_spotify_id_unique": { + "name": "users_spotify_id_unique", + "columns": [ + "spotify_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle-sqlite-archive/meta/0001_snapshot.json b/drizzle-sqlite-archive/meta/0001_snapshot.json new file mode 100644 index 0000000..94020bc --- /dev/null +++ b/drizzle-sqlite-archive/meta/0001_snapshot.json @@ -0,0 +1,705 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dedcd828-c891-47b4-a097-2f50d0770575", + "prevId": "c774036a-b1d3-490e-9930-91ec7fc76e5d", + "tables": { + "jam_members": { + "name": "jam_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "jam_id": { + "name": "jam_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "jam_members_jam_user_idx": { + "name": "jam_members_jam_user_idx", + "columns": [ + "jam_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "jam_members_jam_id_jams_id_fk": { + "name": "jam_members_jam_id_jams_id_fk", + "tableFrom": "jam_members", + "tableTo": "jams", + "columnsFrom": [ + "jam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "jam_members_user_id_users_id_fk": { + "name": "jam_members_user_id_users_id_fk", + "tableFrom": "jam_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jam_tracks": { + "name": "jam_tracks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "jam_id": { + "name": "jam_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spotify_track_uri": { + "name": "spotify_track_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spotify_track_id": { + "name": "spotify_track_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_name": { + "name": "track_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artist_name": { + "name": "artist_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "album_name": { + "name": "album_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "album_image_url": { + "name": "album_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_by_user_id": { + "name": "added_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "jam_tracks_jam_uri_idx": { + "name": "jam_tracks_jam_uri_idx", + "columns": [ + "jam_id", + "spotify_track_uri" + ], + "isUnique": true + } + }, + "foreignKeys": { + "jam_tracks_jam_id_jams_id_fk": { + "name": "jam_tracks_jam_id_jams_id_fk", + "tableFrom": "jam_tracks", + "tableTo": "jams", + "columnsFrom": [ + "jam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "jam_tracks_added_by_user_id_users_id_fk": { + "name": "jam_tracks_added_by_user_id_users_id_fk", + "tableFrom": "jam_tracks", + "tableTo": "users", + "columnsFrom": [ + "added_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jams": { + "name": "jams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spotify_playlist_id": { + "name": "spotify_playlist_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invite_code": { + "name": "invite_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archive_playlist_id": { + "name": "archive_playlist_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archive_threshold": { + "name": "archive_threshold", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "jams_invite_code_unique": { + "name": "jams_invite_code_unique", + "columns": [ + "invite_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "jams_owner_id_users_id_fk": { + "name": "jams_owner_id_users_id_fk", + "tableFrom": "jams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sub_user_endpoint_idx": { + "name": "push_sub_user_endpoint_idx", + "columns": [ + "user_id", + "endpoint" + ], + "isUnique": true + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "track_listens": { + "name": "track_listens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "jam_id": { + "name": "jam_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spotify_track_id": { + "name": "spotify_track_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listened_at": { + "name": "listened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "track_listens_jam_track_user_idx": { + "name": "track_listens_jam_track_user_idx", + "columns": [ + "jam_id", + "spotify_track_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "track_listens_jam_id_jams_id_fk": { + "name": "track_listens_jam_id_jams_id_fk", + "tableFrom": "track_listens", + "tableTo": "jams", + "columnsFrom": [ + "jam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "track_listens_user_id_users_id_fk": { + "name": "track_listens_user_id_users_id_fk", + "tableFrom": "track_listens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "track_reactions": { + "name": "track_reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "jam_id": { + "name": "jam_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spotify_track_id": { + "name": "spotify_track_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_auto": { + "name": "is_auto", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "track_reactions_jam_track_user_idx": { + "name": "track_reactions_jam_track_user_idx", + "columns": [ + "jam_id", + "spotify_track_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "track_reactions_jam_id_jams_id_fk": { + "name": "track_reactions_jam_id_jams_id_fk", + "tableFrom": "track_reactions", + "tableTo": "jams", + "columnsFrom": [ + "jam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "track_reactions_user_id_users_id_fk": { + "name": "track_reactions_user_id_users_id_fk", + "tableFrom": "track_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spotify_id": { + "name": "spotify_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notify_push": { + "name": "notify_push", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "notify_email": { + "name": "notify_email", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "auto_negative_reactions": { + "name": "auto_negative_reactions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "recent_emojis": { + "name": "recent_emojis", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_poll_cursor": { + "name": "last_poll_cursor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_spotify_id_unique": { + "name": "users_spotify_id_unique", + "columns": [ + "spotify_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle-sqlite-archive/meta/0002_snapshot.json similarity index 100% rename from drizzle/meta/0002_snapshot.json rename to drizzle-sqlite-archive/meta/0002_snapshot.json diff --git a/drizzle/meta/0003_snapshot.json b/drizzle-sqlite-archive/meta/0003_snapshot.json similarity index 100% rename from drizzle/meta/0003_snapshot.json rename to drizzle-sqlite-archive/meta/0003_snapshot.json diff --git a/drizzle/meta/0004_snapshot.json b/drizzle-sqlite-archive/meta/0004_snapshot.json similarity index 100% rename from drizzle/meta/0004_snapshot.json rename to drizzle-sqlite-archive/meta/0004_snapshot.json diff --git a/drizzle/meta/0005_snapshot.json b/drizzle-sqlite-archive/meta/0005_snapshot.json similarity index 100% rename from drizzle/meta/0005_snapshot.json rename to drizzle-sqlite-archive/meta/0005_snapshot.json diff --git a/drizzle-sqlite-archive/meta/_journal.json b/drizzle-sqlite-archive/meta/_journal.json new file mode 100644 index 0000000..58e5c5f --- /dev/null +++ b/drizzle-sqlite-archive/meta/_journal.json @@ -0,0 +1,62 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1771435568183, + "tag": "0000_robust_overlord", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1771445594455, + "tag": "0001_slimy_hedge_knight", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1771446410166, + "tag": "0002_colorful_gorgon", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1771447813789, + "tag": "0003_fresh_jetstream", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1771447838991, + "tag": "0004_mighty_black_widow", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1771450410754, + "tag": "0005_violet_mercury", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1771455600000, + "tag": "0006_rename_jam_to_playlist", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1771542000000, + "tag": "0007_add_removal_delay", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts index 9f1c212..cc7ecf4 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,10 +1,10 @@ -import { defineConfig } from "drizzle-kit"; +import { defineConfig } from 'drizzle-kit'; export default defineConfig({ - schema: "./src/db/schema.ts", - out: "./drizzle", - dialect: "sqlite", + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', dbCredentials: { - url: process.env.DATABASE_PATH ?? "./data/swapify.db", + url: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/swapify', }, }); diff --git a/drizzle/0000_mean_selene.sql b/drizzle/0000_mean_selene.sql new file mode 100644 index 0000000..3516727 --- /dev/null +++ b/drizzle/0000_mean_selene.sql @@ -0,0 +1,119 @@ +CREATE TABLE "email_invites" ( + "id" text PRIMARY KEY NOT NULL, + "playlist_id" text NOT NULL, + "sender_user_id" text NOT NULL, + "recipient_email" text NOT NULL, + "sent_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "playlist_members" ( + "id" text PRIMARY KEY NOT NULL, + "playlist_id" text NOT NULL, + "user_id" text NOT NULL, + "liked_playlist_id" text, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "playlist_tracks" ( + "id" text PRIMARY KEY NOT NULL, + "playlist_id" text NOT NULL, + "spotify_track_uri" text NOT NULL, + "spotify_track_id" text NOT NULL, + "track_name" text NOT NULL, + "artist_name" text NOT NULL, + "album_name" text, + "album_image_url" text, + "duration_ms" integer, + "added_by_user_id" text NOT NULL, + "added_at" timestamp with time zone DEFAULT now() NOT NULL, + "removed_at" timestamp with time zone, + "archived_at" timestamp with time zone, + "completed_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "playlists" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "description" text, + "image_url" text, + "spotify_playlist_id" text NOT NULL, + "owner_id" text NOT NULL, + "invite_code" text NOT NULL, + "archive_playlist_id" text, + "archive_threshold" text DEFAULT 'none' NOT NULL, + "max_tracks_per_user" integer, + "max_track_age_days" integer DEFAULT 7 NOT NULL, + "removal_delay" text DEFAULT 'immediate' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "playlists_invite_code_unique" UNIQUE("invite_code") +); +--> statement-breakpoint +CREATE TABLE "push_subscriptions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "endpoint" text NOT NULL, + "p256dh" text NOT NULL, + "auth" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "track_listens" ( + "id" text PRIMARY KEY NOT NULL, + "playlist_id" text NOT NULL, + "spotify_track_id" text NOT NULL, + "user_id" text NOT NULL, + "listened_at" timestamp with time zone NOT NULL, + "listen_duration_ms" integer, + "was_skipped" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE "track_reactions" ( + "id" text PRIMARY KEY NOT NULL, + "playlist_id" text NOT NULL, + "spotify_track_id" text NOT NULL, + "user_id" text NOT NULL, + "reaction" text NOT NULL, + "is_auto" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" text PRIMARY KEY NOT NULL, + "spotify_id" text NOT NULL, + "display_name" text NOT NULL, + "avatar_url" text, + "email" text, + "pending_email" text, + "email_verify_token" text, + "email_verify_expires_at" integer, + "notify_push" boolean DEFAULT true NOT NULL, + "notify_email" boolean DEFAULT false NOT NULL, + "auto_negative_reactions" boolean DEFAULT true NOT NULL, + "recent_emojis" text, + "access_token" text NOT NULL, + "refresh_token" text NOT NULL, + "token_expires_at" integer NOT NULL, + "last_poll_cursor" integer, + "last_playback_json" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_spotify_id_unique" UNIQUE("spotify_id") +); +--> statement-breakpoint +ALTER TABLE "email_invites" ADD CONSTRAINT "email_invites_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "email_invites" ADD CONSTRAINT "email_invites_sender_user_id_users_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlist_members" ADD CONSTRAINT "playlist_members_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlist_members" ADD CONSTRAINT "playlist_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlist_tracks" ADD CONSTRAINT "playlist_tracks_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlist_tracks" ADD CONSTRAINT "playlist_tracks_added_by_user_id_users_id_fk" FOREIGN KEY ("added_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlists" ADD CONSTRAINT "playlists_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "track_listens" ADD CONSTRAINT "track_listens_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "track_listens" ADD CONSTRAINT "track_listens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "track_reactions" ADD CONSTRAINT "track_reactions_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "track_reactions" ADD CONSTRAINT "track_reactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "email_invites_playlist_email_idx" ON "email_invites" USING btree ("playlist_id","recipient_email");--> statement-breakpoint +CREATE UNIQUE INDEX "playlist_members_playlist_user_idx" ON "playlist_members" USING btree ("playlist_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "playlist_tracks_playlist_uri_idx" ON "playlist_tracks" USING btree ("playlist_id","spotify_track_uri");--> statement-breakpoint +CREATE UNIQUE INDEX "push_sub_user_endpoint_idx" ON "push_subscriptions" USING btree ("user_id","endpoint");--> statement-breakpoint +CREATE UNIQUE INDEX "track_listens_playlist_track_user_idx" ON "track_listens" USING btree ("playlist_id","spotify_track_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "track_reactions_playlist_track_user_idx" ON "track_reactions" USING btree ("playlist_id","spotify_track_id","user_id"); \ No newline at end of file diff --git a/drizzle/0001_warm_killmonger.sql b/drizzle/0001_warm_killmonger.sql new file mode 100644 index 0000000..c07e467 --- /dev/null +++ b/drizzle/0001_warm_killmonger.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "notification_prefs" text; \ No newline at end of file diff --git a/drizzle/0002_add_vibe_name.sql b/drizzle/0002_add_vibe_name.sql new file mode 100644 index 0000000..c336042 --- /dev/null +++ b/drizzle/0002_add_vibe_name.sql @@ -0,0 +1 @@ +ALTER TABLE playlists ADD COLUMN vibe_name TEXT; diff --git a/drizzle/0009_add_notification_prefs.sql b/drizzle/0009_add_notification_prefs.sql new file mode 100644 index 0000000..ff4b935 --- /dev/null +++ b/drizzle/0009_add_notification_prefs.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "notification_prefs" text; diff --git a/drizzle/0010_add_spotify_client_id.sql b/drizzle/0010_add_spotify_client_id.sql new file mode 100644 index 0000000..02bc3f5 --- /dev/null +++ b/drizzle/0010_add_spotify_client_id.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "spotify_client_id" text; diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index a5b04b1..fb01b6c 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,58 +1,168 @@ { - "version": "6", - "dialect": "sqlite", - "id": "c774036a-b1d3-490e-9930-91ec7fc76e5d", + "id": "1eb582a4-32f0-410d-bd68-d0c62bb6f32c", "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", "tables": { - "jam_members": { - "name": "jam_members", + "public.email_invites": { + "name": "email_invites", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": true }, - "jam_id": { - "name": "jam_id", + "recipient_email": { + "name": "recipient_email", "type": "text", "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" + } + }, + "indexes": { + "email_invites_playlist_email_idx": { + "name": "email_invites_playlist_email_idx", + "columns": [ + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_invites_playlist_id_playlists_id_fk": { + "name": "email_invites_playlist_id_playlists_id_fk", + "tableFrom": "email_invites", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_invites_sender_user_id_users_id_fk": { + "name": "email_invites_sender_user_id_users_id_fk", + "tableFrom": "email_invites", + "tableTo": "users", + "columnsFrom": [ + "sender_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlist_members": { + "name": "playlist_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "text", + "primaryKey": false, + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "liked_playlist_id": { + "name": "liked_playlist_id", + "type": "text", + "primaryKey": false, + "notNull": false }, "joined_at": { "name": "joined_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" } }, "indexes": { - "jam_members_jam_user_idx": { - "name": "jam_members_jam_user_idx", + "playlist_members_playlist_user_idx": { + "name": "playlist_members_playlist_user_idx", "columns": [ - "jam_id", - "user_id" + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "isUnique": true + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "jam_members_jam_id_jams_id_fk": { - "name": "jam_members_jam_id_jams_id_fk", - "tableFrom": "jam_members", - "tableTo": "jams", + "playlist_members_playlist_id_playlists_id_fk": { + "name": "playlist_members_playlist_id_playlists_id_fk", + "tableFrom": "playlist_members", + "tableTo": "playlists", "columnsFrom": [ - "jam_id" + "playlist_id" ], "columnsTo": [ "id" @@ -60,9 +170,9 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "jam_members_user_id_users_id_fk": { - "name": "jam_members_user_id_users_id_fk", - "tableFrom": "jam_members", + "playlist_members_user_id_users_id_fk": { + "name": "playlist_members_user_id_users_id_fk", + "tableFrom": "playlist_members", "tableTo": "users", "columnsFrom": [ "user_id" @@ -76,113 +186,130 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "jam_tracks": { - "name": "jam_tracks", + "public.playlist_tracks": { + "name": "playlist_tracks", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, - "jam_id": { - "name": "jam_id", + "playlist_id": { + "name": "playlist_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "spotify_track_uri": { "name": "spotify_track_uri", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "spotify_track_id": { "name": "spotify_track_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "track_name": { "name": "track_name", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "artist_name": { "name": "artist_name", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "album_name": { "name": "album_name", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "album_image_url": { "name": "album_image_url", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "duration_ms": { "name": "duration_ms", "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "added_by_user_id": { "name": "added_by_user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "added_at": { "name": "added_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" }, "removed_at": { "name": "removed_at", - "type": "integer", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": { - "jam_tracks_jam_uri_idx": { - "name": "jam_tracks_jam_uri_idx", + "playlist_tracks_playlist_uri_idx": { + "name": "playlist_tracks_playlist_uri_idx", "columns": [ - "jam_id", - "spotify_track_uri" + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spotify_track_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "isUnique": true + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "jam_tracks_jam_id_jams_id_fk": { - "name": "jam_tracks_jam_id_jams_id_fk", - "tableFrom": "jam_tracks", - "tableTo": "jams", + "playlist_tracks_playlist_id_playlists_id_fk": { + "name": "playlist_tracks_playlist_id_playlists_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "playlists", "columnsFrom": [ - "jam_id" + "playlist_id" ], "columnsTo": [ "id" @@ -190,9 +317,9 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "jam_tracks_added_by_user_id_users_id_fk": { - "name": "jam_tracks_added_by_user_id_users_id_fk", - "tableFrom": "jam_tracks", + "playlist_tracks_added_by_user_id_users_id_fk": { + "name": "playlist_tracks_added_by_user_id_users_id_fk", + "tableFrom": "playlist_tracks", "tableTo": "users", "columnsFrom": [ "added_by_user_id" @@ -206,81 +333,102 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "jams": { - "name": "jams", + "public.playlists": { + "name": "playlists", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "name": { "name": "name", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "description": { "name": "description", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "image_url": { "name": "image_url", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "spotify_playlist_id": { "name": "spotify_playlist_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "owner_id": { "name": "owner_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "invite_code": { "name": "invite_code", "type": "text", "primaryKey": false, + "notNull": true + }, + "archive_playlist_id": { + "name": "archive_playlist_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archive_threshold": { + "name": "archive_threshold", + "type": "text", + "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "'none'" + }, + "max_tracks_per_user": { + "name": "max_tracks_per_user", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_track_age_days": { + "name": "max_track_age_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 7 + }, + "removal_delay": { + "name": "removal_delay", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'immediate'" }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "jams_invite_code_unique": { - "name": "jams_invite_code_unique", - "columns": [ - "invite_code" - ], - "isUnique": true + "default": "now()" } }, + "indexes": {}, "foreignKeys": { - "jams_owner_id_users_id_fk": { - "name": "jams_owner_id_users_id_fk", - "tableFrom": "jams", + "playlists_owner_id_users_id_fk": { + "name": "playlists_owner_id_users_id_fk", + "tableFrom": "playlists", "tableTo": "users", "columnsFrom": [ "owner_id" @@ -293,63 +441,82 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "uniqueConstraints": { + "playlists_invite_code_unique": { + "name": "playlists_invite_code_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "push_subscriptions": { + "public.push_subscriptions": { "name": "push_subscriptions", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "endpoint": { "name": "endpoint", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "p256dh": { "name": "p256dh", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "auth": { "name": "auth", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" } }, "indexes": { "push_sub_user_endpoint_idx": { "name": "push_sub_user_endpoint_idx", "columns": [ - "user_id", - "endpoint" + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "isUnique": true + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -369,65 +536,94 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "track_listens": { + "public.track_listens": { "name": "track_listens", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, - "jam_id": { - "name": "jam_id", + "playlist_id": { + "name": "playlist_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "spotify_track_id": { "name": "spotify_track_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "listened_at": { "name": "listened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "listen_duration_ms": { + "name": "listen_duration_ms", "type": "integer", "primaryKey": false, + "notNull": false + }, + "was_skipped": { + "name": "was_skipped", + "type": "boolean", + "primaryKey": false, "notNull": true, - "autoincrement": false + "default": false } }, "indexes": { - "track_listens_jam_track_user_idx": { - "name": "track_listens_jam_track_user_idx", + "track_listens_playlist_track_user_idx": { + "name": "track_listens_playlist_track_user_idx", "columns": [ - "jam_id", - "spotify_track_id", - "user_id" + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spotify_track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "isUnique": true + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "track_listens_jam_id_jams_id_fk": { - "name": "track_listens_jam_id_jams_id_fk", + "track_listens_playlist_id_playlists_id_fk": { + "name": "track_listens_playlist_id_playlists_id_fk", "tableFrom": "track_listens", - "tableTo": "jams", + "tableTo": "playlists", "columnsFrom": [ - "jam_id" + "playlist_id" ], "columnsTo": [ "id" @@ -451,80 +647,95 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "track_reactions": { + "public.track_reactions": { "name": "track_reactions", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, - "jam_id": { - "name": "jam_id", + "playlist_id": { + "name": "playlist_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "spotify_track_id": { "name": "spotify_track_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "reaction": { "name": "reaction", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "is_auto": { "name": "is_auto", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": 0 + "default": false }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" } }, "indexes": { - "track_reactions_jam_track_user_idx": { - "name": "track_reactions_jam_track_user_idx", + "track_reactions_playlist_track_user_idx": { + "name": "track_reactions_playlist_track_user_idx", "columns": [ - "jam_id", - "spotify_track_id", - "user_id" + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spotify_track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "isUnique": true + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "track_reactions_jam_id_jams_id_fk": { - "name": "track_reactions_jam_id_jams_id_fk", + "track_reactions_playlist_id_playlists_id_fk": { + "name": "track_reactions_playlist_id_playlists_id_fk", "tableFrom": "track_reactions", - "tableTo": "jams", + "tableTo": "playlists", "columnsFrom": [ - "jam_id" + "playlist_id" ], "columnsTo": [ "id" @@ -548,136 +759,153 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "users": { + "public.users": { "name": "users", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "spotify_id": { "name": "spotify_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "display_name": { "name": "display_name", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "avatar_url": { "name": "avatar_url", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "email": { "name": "email", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "pending_email": { + "name": "pending_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verify_token": { + "name": "email_verify_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verify_expires_at": { + "name": "email_verify_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false }, "notify_push": { "name": "notify_push", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": 1 + "default": true }, "notify_email": { "name": "notify_email", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": 0 + "default": false }, "auto_negative_reactions": { "name": "auto_negative_reactions", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": 1 + "default": true }, "recent_emojis": { "name": "recent_emojis", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "access_token": { "name": "access_token", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "token_expires_at": { "name": "token_expires_at", "type": "integer", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "last_poll_cursor": { "name": "last_poll_cursor", "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "last_playback_json": { + "name": "last_playback_json", + "type": "text", + "primaryKey": false, + "notNull": false }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" } }, - "indexes": { + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "users_spotify_id_unique": { "name": "users_spotify_id_unique", + "nullsNotDistinct": false, "columns": [ "spotify_id" - ], - "isUnique": true + ] } }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, - "views": {}, "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, "_meta": { + "columns": {}, "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} + "tables": {} } } \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json index 94020bc..b411492 100644 --- a/drizzle/meta/0001_snapshot.json +++ b/drizzle/meta/0001_snapshot.json @@ -1,58 +1,168 @@ { - "version": "6", - "dialect": "sqlite", - "id": "dedcd828-c891-47b4-a097-2f50d0770575", - "prevId": "c774036a-b1d3-490e-9930-91ec7fc76e5d", + "id": "b2b02727-e8de-453c-9d4a-e250a0637089", + "prevId": "1eb582a4-32f0-410d-bd68-d0c62bb6f32c", + "version": "7", + "dialect": "postgresql", "tables": { - "jam_members": { - "name": "jam_members", + "public.email_invites": { + "name": "email_invites", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": true }, - "jam_id": { - "name": "jam_id", + "recipient_email": { + "name": "recipient_email", "type": "text", "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" + } + }, + "indexes": { + "email_invites_playlist_email_idx": { + "name": "email_invites_playlist_email_idx", + "columns": [ + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_invites_playlist_id_playlists_id_fk": { + "name": "email_invites_playlist_id_playlists_id_fk", + "tableFrom": "email_invites", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_invites_sender_user_id_users_id_fk": { + "name": "email_invites_sender_user_id_users_id_fk", + "tableFrom": "email_invites", + "tableTo": "users", + "columnsFrom": [ + "sender_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlist_members": { + "name": "playlist_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "text", + "primaryKey": false, + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "liked_playlist_id": { + "name": "liked_playlist_id", + "type": "text", + "primaryKey": false, + "notNull": false }, "joined_at": { "name": "joined_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" } }, "indexes": { - "jam_members_jam_user_idx": { - "name": "jam_members_jam_user_idx", + "playlist_members_playlist_user_idx": { + "name": "playlist_members_playlist_user_idx", "columns": [ - "jam_id", - "user_id" + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "isUnique": true + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "jam_members_jam_id_jams_id_fk": { - "name": "jam_members_jam_id_jams_id_fk", - "tableFrom": "jam_members", - "tableTo": "jams", + "playlist_members_playlist_id_playlists_id_fk": { + "name": "playlist_members_playlist_id_playlists_id_fk", + "tableFrom": "playlist_members", + "tableTo": "playlists", "columnsFrom": [ - "jam_id" + "playlist_id" ], "columnsTo": [ "id" @@ -60,9 +170,9 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "jam_members_user_id_users_id_fk": { - "name": "jam_members_user_id_users_id_fk", - "tableFrom": "jam_members", + "playlist_members_user_id_users_id_fk": { + "name": "playlist_members_user_id_users_id_fk", + "tableFrom": "playlist_members", "tableTo": "users", "columnsFrom": [ "user_id" @@ -76,120 +186,130 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "jam_tracks": { - "name": "jam_tracks", + "public.playlist_tracks": { + "name": "playlist_tracks", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, - "jam_id": { - "name": "jam_id", + "playlist_id": { + "name": "playlist_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "spotify_track_uri": { "name": "spotify_track_uri", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "spotify_track_id": { "name": "spotify_track_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "track_name": { "name": "track_name", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "artist_name": { "name": "artist_name", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "album_name": { "name": "album_name", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "album_image_url": { "name": "album_image_url", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "duration_ms": { "name": "duration_ms", "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "added_by_user_id": { "name": "added_by_user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "added_at": { "name": "added_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" }, "removed_at": { "name": "removed_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "archived_at": { "name": "archived_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": { - "jam_tracks_jam_uri_idx": { - "name": "jam_tracks_jam_uri_idx", + "playlist_tracks_playlist_uri_idx": { + "name": "playlist_tracks_playlist_uri_idx", "columns": [ - "jam_id", - "spotify_track_uri" + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spotify_track_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "isUnique": true + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "jam_tracks_jam_id_jams_id_fk": { - "name": "jam_tracks_jam_id_jams_id_fk", - "tableFrom": "jam_tracks", - "tableTo": "jams", + "playlist_tracks_playlist_id_playlists_id_fk": { + "name": "playlist_tracks_playlist_id_playlists_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "playlists", "columnsFrom": [ - "jam_id" + "playlist_id" ], "columnsTo": [ "id" @@ -197,9 +317,9 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "jam_tracks_added_by_user_id_users_id_fk": { - "name": "jam_tracks_added_by_user_id_users_id_fk", - "tableFrom": "jam_tracks", + "playlist_tracks_added_by_user_id_users_id_fk": { + "name": "playlist_tracks_added_by_user_id_users_id_fk", + "tableFrom": "playlist_tracks", "tableTo": "users", "columnsFrom": [ "added_by_user_id" @@ -213,96 +333,102 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "jams": { - "name": "jams", + "public.playlists": { + "name": "playlists", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "name": { "name": "name", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "description": { "name": "description", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "image_url": { "name": "image_url", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "spotify_playlist_id": { "name": "spotify_playlist_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "owner_id": { "name": "owner_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "invite_code": { "name": "invite_code", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "archive_playlist_id": { "name": "archive_playlist_id", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "archive_threshold": { "name": "archive_threshold", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'none'" }, + "max_tracks_per_user": { + "name": "max_tracks_per_user", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_track_age_days": { + "name": "max_track_age_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 7 + }, + "removal_delay": { + "name": "removal_delay", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'immediate'" + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "jams_invite_code_unique": { - "name": "jams_invite_code_unique", - "columns": [ - "invite_code" - ], - "isUnique": true + "default": "now()" } }, + "indexes": {}, "foreignKeys": { - "jams_owner_id_users_id_fk": { - "name": "jams_owner_id_users_id_fk", - "tableFrom": "jams", + "playlists_owner_id_users_id_fk": { + "name": "playlists_owner_id_users_id_fk", + "tableFrom": "playlists", "tableTo": "users", "columnsFrom": [ "owner_id" @@ -315,63 +441,82 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "uniqueConstraints": { + "playlists_invite_code_unique": { + "name": "playlists_invite_code_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "push_subscriptions": { + "public.push_subscriptions": { "name": "push_subscriptions", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "endpoint": { "name": "endpoint", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "p256dh": { "name": "p256dh", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "auth": { "name": "auth", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" } }, "indexes": { "push_sub_user_endpoint_idx": { "name": "push_sub_user_endpoint_idx", "columns": [ - "user_id", - "endpoint" + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "isUnique": true + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -391,65 +536,94 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "track_listens": { + "public.track_listens": { "name": "track_listens", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, - "jam_id": { - "name": "jam_id", + "playlist_id": { + "name": "playlist_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "spotify_track_id": { "name": "spotify_track_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "listened_at": { "name": "listened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "listen_duration_ms": { + "name": "listen_duration_ms", "type": "integer", "primaryKey": false, + "notNull": false + }, + "was_skipped": { + "name": "was_skipped", + "type": "boolean", + "primaryKey": false, "notNull": true, - "autoincrement": false + "default": false } }, "indexes": { - "track_listens_jam_track_user_idx": { - "name": "track_listens_jam_track_user_idx", + "track_listens_playlist_track_user_idx": { + "name": "track_listens_playlist_track_user_idx", "columns": [ - "jam_id", - "spotify_track_id", - "user_id" + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spotify_track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "isUnique": true + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "track_listens_jam_id_jams_id_fk": { - "name": "track_listens_jam_id_jams_id_fk", + "track_listens_playlist_id_playlists_id_fk": { + "name": "track_listens_playlist_id_playlists_id_fk", "tableFrom": "track_listens", - "tableTo": "jams", + "tableTo": "playlists", "columnsFrom": [ - "jam_id" + "playlist_id" ], "columnsTo": [ "id" @@ -473,80 +647,95 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "track_reactions": { + "public.track_reactions": { "name": "track_reactions", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, - "jam_id": { - "name": "jam_id", + "playlist_id": { + "name": "playlist_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "spotify_track_id": { "name": "spotify_track_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "reaction": { "name": "reaction", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "is_auto": { "name": "is_auto", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": 0 + "default": false }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" } }, "indexes": { - "track_reactions_jam_track_user_idx": { - "name": "track_reactions_jam_track_user_idx", + "track_reactions_playlist_track_user_idx": { + "name": "track_reactions_playlist_track_user_idx", "columns": [ - "jam_id", - "spotify_track_id", - "user_id" + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spotify_track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "isUnique": true + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "track_reactions_jam_id_jams_id_fk": { - "name": "track_reactions_jam_id_jams_id_fk", + "track_reactions_playlist_id_playlists_id_fk": { + "name": "track_reactions_playlist_id_playlists_id_fk", "tableFrom": "track_reactions", - "tableTo": "jams", + "tableTo": "playlists", "columnsFrom": [ - "jam_id" + "playlist_id" ], "columnsTo": [ "id" @@ -570,136 +759,159 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "users": { + "public.users": { "name": "users", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "spotify_id": { "name": "spotify_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "display_name": { "name": "display_name", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "avatar_url": { "name": "avatar_url", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "email": { "name": "email", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "pending_email": { + "name": "pending_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verify_token": { + "name": "email_verify_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verify_expires_at": { + "name": "email_verify_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false }, "notify_push": { "name": "notify_push", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": 1 + "default": true }, "notify_email": { "name": "notify_email", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": 0 + "default": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "text", + "primaryKey": false, + "notNull": false }, "auto_negative_reactions": { "name": "auto_negative_reactions", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": 1 + "default": true }, "recent_emojis": { "name": "recent_emojis", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "access_token": { "name": "access_token", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "token_expires_at": { "name": "token_expires_at", "type": "integer", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "last_poll_cursor": { "name": "last_poll_cursor", "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "last_playback_json": { + "name": "last_playback_json", + "type": "text", + "primaryKey": false, + "notNull": false }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "now()" } }, - "indexes": { + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "users_spotify_id_unique": { "name": "users_spotify_id_unique", + "nullsNotDistinct": false, "columns": [ "spotify_id" - ], - "isUnique": true + ] } }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, - "views": {}, "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, "_meta": { + "columns": {}, "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} + "tables": {} } } \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 58e5c5f..be3ff7e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,61 +1,40 @@ { "version": "7", - "dialect": "sqlite", + "dialect": "postgresql", "entries": [ { "idx": 0, - "version": "6", - "when": 1771435568183, - "tag": "0000_robust_overlord", + "version": "7", + "when": 1771517612891, + "tag": "0000_mean_selene", "breakpoints": true }, { "idx": 1, - "version": "6", - "when": 1771445594455, - "tag": "0001_slimy_hedge_knight", + "version": "7", + "when": 1771518148280, + "tag": "0001_warm_killmonger", "breakpoints": true }, { "idx": 2, - "version": "6", - "when": 1771446410166, - "tag": "0002_colorful_gorgon", + "version": "7", + "when": 1771600000000, + "tag": "0002_add_vibe_name", "breakpoints": true }, { "idx": 3, - "version": "6", - "when": 1771447813789, - "tag": "0003_fresh_jetstream", + "version": "7", + "when": 1771700000000, + "tag": "0009_add_notification_prefs", "breakpoints": true }, { "idx": 4, - "version": "6", - "when": 1771447838991, - "tag": "0004_mighty_black_widow", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1771450410754, - "tag": "0005_violet_mercury", - "breakpoints": true - }, - { - "idx": 6, - "version": "6", - "when": 1771455600000, - "tag": "0006_rename_jam_to_playlist", - "breakpoints": true - }, - { - "idx": 7, - "version": "6", - "when": 1771542000000, - "tag": "0007_add_removal_delay", + "version": "7", + "when": 1771800000000, + "tag": "0010_add_spotify_client_id", "breakpoints": true } ] diff --git a/fly.toml b/fly.toml index 3728f0f..c2311dc 100644 --- a/fly.toml +++ b/fly.toml @@ -6,18 +6,22 @@ primary_region = "ord" [env] NODE_ENV = "production" NEXT_PUBLIC_APP_URL = "https://swapify.312.dev" - DATABASE_PATH = "/data/swapify.db" + # DATABASE_URL set via `fly secrets set DATABASE_URL=...` + # ANTHROPIC_API_KEY set via `fly secrets set ANTHROPIC_API_KEY=...` [http_service] internal_port = 3000 force_https = true auto_stop_machines = "stop" auto_start_machines = true - min_machines_running = 0 + min_machines_running = 1 -[mounts] - source = "swapify_data" - destination = "/data" +[[http_service.checks]] + interval = "15s" + grace_period = "10s" + method = "GET" + path = "/api/health" + timeout = "5s" [[vm]] memory = "512mb" diff --git a/next.config.ts b/next.config.ts index 0f19d42..24b3be2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,14 +1,41 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'standalone', + serverExternalPackages: ['@electric-sql/pglite', 'pg', 'pino'], async headers() { return [ { - source: "/sw.js", + source: '/(.*)', headers: [ - { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }, - { key: "Service-Worker-Allowed", value: "/" }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, + { + key: 'Content-Security-Policy', + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https://i.scdn.co https://mosaic.scdn.co https://image-cdn-ak.spotifycdn.com https://image-cdn-fa.spotifycdn.com https://wrapped-images.spotifycdn.com blob:", + "connect-src 'self' https://api.spotify.com https://accounts.spotify.com", + "font-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + "worker-src 'self'", + "manifest-src 'self'", + ].join('; '), + }, + ], + }, + { + source: '/sw.js', + headers: [ + { key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' }, + { key: 'Service-Worker-Allowed', value: '/' }, ], }, ]; diff --git a/package-lock.json b/package-lock.json index a76ec51..e9a8ad6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,11 @@ "name": "swapify", "version": "0.1.0", "dependencies": { + "@anthropic-ai/sdk": "^0.77.0", + "@electric-sql/pglite": "^0.3.15", + "@types/pg": "^8.16.0", "better-sqlite3": "^12.6.2", + "cal-sans": "^1.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", @@ -18,6 +22,8 @@ "motion": "^12.34.2", "nanoid": "^5.1.6", "next": "16.1.6", + "pg": "^8.18.0", + "pino": "^10.3.1", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", @@ -25,7 +31,8 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "vaul": "^1.1.2", - "web-push": "^3.6.7" + "web-push": "^3.6.7", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -81,6 +88,26 @@ "nup": "bin/nup.mjs" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.77.0.tgz", + "integrity": "sha512-TivlT6nfidz3sOyMF72T2x5AkmHrpT7JgL2e/0HNdh7b24v7JC8cR+rCY/42jA68xIsjmiGQ5IKMsH9feEKh3A==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -467,6 +494,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -735,6 +771,13 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -2853,6 +2896,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4792,12 +4841,23 @@ "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -5788,6 +5848,15 @@ "node": ">= 0.4" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -6048,6 +6117,12 @@ "node": ">= 0.8" } }, + "node_modules/cal-sans": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cal-sans/-/cal-sans-1.0.1.tgz", + "integrity": "sha512-XwN3/7jez8WmFVcNnNqO2K9lh133KiIcURCyGFnSM+ZmNZ8zIcOTNfr3SpenLAkRceYsq+fQNX/PL4C1rIkEPQ==", + "license": "SEE LICENSE IN OFL.TXT" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -9563,6 +9638,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -10900,6 +10988,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -11174,6 +11271,96 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11206,6 +11393,43 @@ "node": ">=0.10" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -11294,6 +11518,45 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -11375,6 +11638,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -11482,6 +11761,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", @@ -11722,6 +12007,15 @@ "node": ">= 6" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -12047,6 +12341,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -12542,6 +12845,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -12582,6 +12894,15 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -13001,6 +13322,18 @@ "node": ">=6" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -13123,6 +13456,12 @@ "node": ">=16" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -13391,7 +13730,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -13844,6 +14182,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -13994,7 +14341,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index 059592f..1fd0688 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"", "type-check": "tsc --noEmit", + "db:migrate": "npx tsx src/db/migrate.ts", + "db:generate": "drizzle-kit generate", "db:seed": "npx tsx src/db/seed.ts", "prepare": "husky" }, @@ -24,7 +26,11 @@ ] }, "dependencies": { + "@anthropic-ai/sdk": "^0.77.0", + "@electric-sql/pglite": "^0.3.15", + "@types/pg": "^8.16.0", "better-sqlite3": "^12.6.2", + "cal-sans": "^1.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", @@ -34,6 +40,8 @@ "motion": "^12.34.2", "nanoid": "^5.1.6", "next": "16.1.6", + "pg": "^8.18.0", + "pino": "^10.3.1", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", @@ -41,7 +49,8 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "vaul": "^1.1.2", - "web-push": "^3.6.7" + "web-push": "^3.6.7", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png index 2a8669a..25a28a7 100644 Binary files a/public/icons/icon-192.png and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png index 27606a3..5d084bb 100644 Binary files a/public/icons/icon-512.png and b/public/icons/icon-512.png differ diff --git a/public/icons/swapify-logo.svg b/public/icons/swapify-logo.svg new file mode 100644 index 0000000..e4be2ba --- /dev/null +++ b/public/icons/swapify-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/landing/concert.jpg b/public/images/landing/concert.jpg new file mode 100644 index 0000000..3452767 Binary files /dev/null and b/public/images/landing/concert.jpg differ diff --git a/public/images/landing/friends.jpg b/public/images/landing/friends.jpg new file mode 100644 index 0000000..0a479f1 Binary files /dev/null and b/public/images/landing/friends.jpg differ diff --git a/public/images/landing/hero-dj.jpg b/public/images/landing/hero-dj.jpg new file mode 100644 index 0000000..8f66c33 Binary files /dev/null and b/public/images/landing/hero-dj.jpg differ diff --git a/public/images/mockup/after-hours.jpg b/public/images/mockup/after-hours.jpg new file mode 100644 index 0000000..0bbcc0a Binary files /dev/null and b/public/images/mockup/after-hours.jpg differ diff --git a/public/images/mockup/espresso.jpg b/public/images/mockup/espresso.jpg new file mode 100644 index 0000000..a3b11a3 Binary files /dev/null and b/public/images/mockup/espresso.jpg differ diff --git a/public/images/mockup/future-nostalgia.jpg b/public/images/mockup/future-nostalgia.jpg new file mode 100644 index 0000000..6ee097d Binary files /dev/null and b/public/images/mockup/future-nostalgia.jpg differ diff --git a/public/images/mockup/member-1.jpg b/public/images/mockup/member-1.jpg new file mode 100644 index 0000000..d1ef021 Binary files /dev/null and b/public/images/mockup/member-1.jpg differ diff --git a/public/images/mockup/member-2.jpg b/public/images/mockup/member-2.jpg new file mode 100644 index 0000000..b81b007 Binary files /dev/null and b/public/images/mockup/member-2.jpg differ diff --git a/public/images/mockup/member-3.jpg b/public/images/mockup/member-3.jpg new file mode 100644 index 0000000..95991f7 Binary files /dev/null and b/public/images/mockup/member-3.jpg differ diff --git a/public/images/mockup/weekend-vibes.jpg b/public/images/mockup/weekend-vibes.jpg new file mode 100644 index 0000000..0692df1 Binary files /dev/null and b/public/images/mockup/weekend-vibes.jpg differ diff --git a/public/manifest.json b/public/manifest.json index 8bbf515..8b82f80 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,13 +1,13 @@ { "name": "Swapify", "short_name": "Swapify", - "description": "Collaborative playlists that clean themselves up", + "description": "A shared music inbox for you and your friends", "id": "/dashboard", "start_url": "/dashboard", "scope": "/", "display": "standalone", "background_color": "#0a0a0a", - "theme_color": "#1DB954", + "theme_color": "#38BDF8", "orientation": "portrait-primary", "icons": [ { diff --git a/scripts/deploy-init.sh b/scripts/deploy-init.sh new file mode 100755 index 0000000..f287836 --- /dev/null +++ b/scripts/deploy-init.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +# +# Swapify — One-time production setup +# +# This script: +# 1. Checks prerequisites (fly, neonctl CLIs) +# 2. Creates the Fly.io app +# 3. Provisions a Neon Postgres database +# 4. Generates all secrets +# 5. Sets secrets on Fly.io + GitHub +# 6. Runs database migrations +# 7. Deploys +# 8. Verifies health +# +# Usage: +# ./scripts/deploy-init.sh --spotify-client-id [options] +# +# Options: +# --spotify-client-id Required. From Spotify Developer Dashboard. +# --resend-api-key Optional. For email notifications. +# --anthropic-api-key Optional. For AI vibe name generation. +# --skip-neon Skip Neon provisioning (set DATABASE_URL manually). +# --skip-deploy Stop after setting secrets (don't deploy yet). +# --dry-run Print what would be done without executing. + +set -euo pipefail + +# ─── Colors ────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE}[info]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +error() { echo -e "${RED}[error]${NC} $*" >&2; } +fatal() { error "$*"; exit 1; } + +# ─── Parse Arguments ───────────────────────────────────────────────────────── +SPOTIFY_CLIENT_ID="" +RESEND_API_KEY="" +ANTHROPIC_API_KEY="" +SKIP_NEON=false +SKIP_DEPLOY=false +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case $1 in + --spotify-client-id) SPOTIFY_CLIENT_ID="$2"; shift 2 ;; + --resend-api-key) RESEND_API_KEY="$2"; shift 2 ;; + --anthropic-api-key) ANTHROPIC_API_KEY="$2"; shift 2 ;; + --skip-neon) SKIP_NEON=true; shift ;; + --skip-deploy) SKIP_DEPLOY=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + *) fatal "Unknown option: $1" ;; + esac +done + +if [[ -z "$SPOTIFY_CLIENT_ID" ]]; then + fatal "Missing required --spotify-client-id. Get it from https://developer.spotify.com/dashboard" +fi + +APP_NAME="swapify" +APP_URL="https://swapify.312.dev" +REGION="ord" + +# ─── Dry run wrapper ──────────────────────────────────────────────────────── +run() { + if $DRY_RUN; then + echo -e "${YELLOW}[dry-run]${NC} $*" + else + eval "$@" + fi +} + +# ─── 1. Check Prerequisites ───────────────────────────────────────────────── +echo "" +echo "═══════════════════════════════════════════" +echo " Swapify Production Setup" +echo "═══════════════════════════════════════════" +echo "" + +info "Checking prerequisites..." + +command -v flyctl >/dev/null 2>&1 || fatal "flyctl not found. Install: curl -L https://fly.io/install.sh | sh" +command -v gh >/dev/null 2>&1 || fatal "gh (GitHub CLI) not found. Install: brew install gh" +command -v node >/dev/null 2>&1 || fatal "node not found" +command -v npm >/dev/null 2>&1 || fatal "npm not found" + +if ! $SKIP_NEON; then + command -v neonctl >/dev/null 2>&1 || fatal "neonctl not found. Install: npm i -g neonctl && neonctl auth" +fi + +# Check CLI auth +flyctl auth whoami >/dev/null 2>&1 || fatal "Not logged in to Fly. Run: flyctl auth login" +gh auth status >/dev/null 2>&1 || fatal "Not logged in to GitHub. Run: gh auth login" + +if ! $SKIP_NEON; then + neonctl projects list >/dev/null 2>&1 || fatal "Not logged in to Neon. Run: neonctl auth" +fi + +ok "All prerequisites met" + +# ─── 2. Generate Secrets ──────────────────────────────────────────────────── +info "Generating secrets..." + +IRON_SESSION_PASSWORD=$(openssl rand -base64 32) +POLL_SECRET=$(openssl rand -base64 24) +TOKEN_ENCRYPTION_KEY=$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))") + +ok "Secrets generated" + +# Generate VAPID keys +info "Generating VAPID keys for web push..." +VAPID_OUTPUT=$(npx --yes web-push generate-vapid-keys --json 2>/dev/null) +VAPID_PUBLIC_KEY=$(echo "$VAPID_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));console.log(d.publicKey)") +VAPID_PRIVATE_KEY=$(echo "$VAPID_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));console.log(d.privateKey)") +ok "VAPID keys generated" + +# ─── 3. Provision Neon Database ───────────────────────────────────────────── +DATABASE_URL="" + +if ! $SKIP_NEON; then + info "Provisioning Neon Postgres database..." + + # Create project + NEON_OUTPUT=$(run neonctl projects create --name "$APP_NAME" --region-id aws-us-east-2 --output json 2>/dev/null || echo "") + + if [[ -n "$NEON_OUTPUT" && "$NEON_OUTPUT" != *"dry-run"* ]]; then + DATABASE_URL=$(echo "$NEON_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));console.log(d.connection_uris[0].connection_uri)") + ok "Neon database provisioned" + elif $DRY_RUN; then + DATABASE_URL="postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/neondb?sslmode=require" + ok "Would provision Neon database" + else + fatal "Failed to create Neon project. Create manually at https://neon.tech and use --skip-neon" + fi +else + warn "Skipping Neon provisioning. Set DATABASE_URL manually:" + warn " fly secrets set DATABASE_URL=" +fi + +# ─── 4. Create Fly App ────────────────────────────────────────────────────── +info "Setting up Fly.io app..." + +if flyctl apps list 2>/dev/null | grep -q "$APP_NAME"; then + ok "Fly app '$APP_NAME' already exists" +else + run flyctl apps create "$APP_NAME" --org personal + ok "Fly app '$APP_NAME' created" +fi + +# ─── 5. Set Fly Secrets ───────────────────────────────────────────────────── +info "Setting Fly.io secrets..." + +FLY_SECRETS=( + "SPOTIFY_CLIENT_ID=$SPOTIFY_CLIENT_ID" + "SPOTIFY_REDIRECT_URI=${APP_URL}/api/auth/callback" + "IRON_SESSION_PASSWORD=$IRON_SESSION_PASSWORD" + "POLL_SECRET=$POLL_SECRET" + "TOKEN_ENCRYPTION_KEY=$TOKEN_ENCRYPTION_KEY" + "NEXT_PUBLIC_VAPID_PUBLIC_KEY=$VAPID_PUBLIC_KEY" + "VAPID_PRIVATE_KEY=$VAPID_PRIVATE_KEY" + "VAPID_SUBJECT=mailto:deploy@swapify.312.dev" +) + +if [[ -n "$DATABASE_URL" ]]; then + FLY_SECRETS+=("DATABASE_URL=$DATABASE_URL") +fi + +if [[ -n "$RESEND_API_KEY" ]]; then + FLY_SECRETS+=("RESEND_API_KEY=$RESEND_API_KEY") +fi + +if [[ -n "$ANTHROPIC_API_KEY" ]]; then + FLY_SECRETS+=("ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") +fi + +run flyctl secrets set "${FLY_SECRETS[@]}" --app "$APP_NAME" +ok "Fly secrets set" + +# ─── 6. Set GitHub Secrets ────────────────────────────────────────────────── +info "Setting GitHub Actions secrets..." + +# Get Fly API token for deploy workflow +FLY_API_TOKEN=$(run flyctl tokens create deploy --app "$APP_NAME" -x 999999h 2>/dev/null | tail -1 || echo "") + +REPO=$(gh repo view --json nameWithOwner -q '.nameWithOwner' 2>/dev/null || echo "") +if [[ -z "$REPO" ]]; then + warn "Could not detect GitHub repo. Set these secrets manually:" + warn " gh secret set FLY_API_TOKEN" + warn " gh secret set DATABASE_URL" +else + if [[ -n "$FLY_API_TOKEN" && ! $DRY_RUN ]]; then + echo "$FLY_API_TOKEN" | gh secret set FLY_API_TOKEN --repo "$REPO" + ok "Set FLY_API_TOKEN on $REPO" + elif $DRY_RUN; then + ok "Would set FLY_API_TOKEN on $REPO" + fi + + if [[ -n "$DATABASE_URL" ]]; then + if $DRY_RUN; then + ok "Would set DATABASE_URL on $REPO" + else + echo "$DATABASE_URL" | gh secret set DATABASE_URL --repo "$REPO" + ok "Set DATABASE_URL on $REPO" + fi + fi +fi + +# ─── 7. Run Migrations ────────────────────────────────────────────────────── +if [[ -n "$DATABASE_URL" ]]; then + info "Running database migrations..." + run DATABASE_URL="$DATABASE_URL" npm run db:migrate + ok "Migrations complete" +else + warn "Skipping migrations — no DATABASE_URL set" +fi + +# ─── 8. Deploy ────────────────────────────────────────────────────────────── +if ! $SKIP_DEPLOY; then + info "Deploying to Fly.io..." + run flyctl deploy --remote-only --app "$APP_NAME" + ok "Deployed!" + + # ─── 9. Verify ────────────────────────────────────────────────────────── + info "Waiting for health check..." + sleep 10 + + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${APP_URL}/api/health" 2>/dev/null || echo "000") + + if [[ "$HTTP_STATUS" == "200" ]]; then + ok "Health check passed!" + else + warn "Health check returned $HTTP_STATUS — check logs: flyctl logs --app $APP_NAME" + fi +else + info "Skipping deploy (--skip-deploy). Deploy manually or push to main." +fi + +# ─── Summary ───────────────────────────────────────────────────────────────── +echo "" +echo "═══════════════════════════════════════════" +echo " Setup Complete!" +echo "═══════════════════════════════════════════" +echo "" +echo " App URL: $APP_URL" +echo " Fly Dashboard: https://fly.io/apps/$APP_NAME" +if [[ -n "$REPO" ]]; then +echo " GitHub Repo: https://github.com/$REPO" +fi +echo "" +echo -e "${YELLOW}Manual steps remaining:${NC}" +echo "" +echo " 1. Spotify Developer Dashboard (https://developer.spotify.com/dashboard):" +echo " - Add redirect URI: ${APP_URL}/api/auth/callback" +echo " - Add beta tester emails under User Management" +echo "" +echo " 2. DNS: Add CNAME record" +echo " swapify.312.dev → ${APP_NAME}.fly.dev" +echo "" +echo " 3. Fly TLS cert (after DNS is set):" +echo " flyctl certs add swapify.312.dev --app $APP_NAME" +echo "" +echo " 4. Future deploys happen automatically on push to main." +echo "" diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index db493e3..9f1b2f9 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -1,148 +1,130 @@ #!/usr/bin/env node /** - * Generate PWA icons for Swapify. - * Creates simple branded icons using raw PNG construction with Node.js built-ins. - * No external dependencies needed. + * Generate PWA icons + favicon for Swapify. + * Uses the Noun Project "Share Song" SVG (by S. Belalcazar Lareo) + * rendered via sharp onto dark rounded backgrounds. */ -import { writeFileSync, mkdirSync } from "fs"; -import { deflateSync } from "zlib"; +import { writeFileSync, mkdirSync } from "node:fs"; +import sharp from "sharp"; -function createPNG(size) { - // Swapify brand colors - const bgR = 10, bgG = 10, bgB = 10; // #0a0a0a background - const fgR = 29, fgG = 185, fgB = 84; // #1DB954 Spotify green +// Swapify brand colors +const BRAND = "#38BDF8"; // sky blue +const BG = "#0a0a0a"; // app dark background - // Create raw pixel data (RGBA) with filter byte per row - const rawData = Buffer.alloc(size * (size * 4 + 1)); +// The Noun Project "Share Song" icon path (5120-unit coordinate system, y-inverted) +const ICON_PATH = `M1483 5105 c-170 -46 -304 -181 -348 -350 -12 -47 -15 -123 -15 -372 l0 -313 -47 23 c-100 50 -152 62 -273 62 -94 0 -128 -4 -185 -23 -109 -36 -193 -88 -271 -167 -244 -247 -244 -643 1 -891 254 -257 657 -258 907 -1 l48 48 872 -386 873 -387 2 -111 c1 -62 3 -123 5 -137 3 -23 -51 -54 -802 -471 l-805 -447 -3 304 c-3 341 -1 351 64 400 l37 29 217 5 217 5 37 29 c71 54 85 151 32 221 -46 59 -72 65 -293 65 -217 0 -285 -11 -375 -56 -71 -36 -159 -123 -197 -193 -56 -106 -61 -143 -61 -488 l0 -313 -47 23 c-100 50 -152 62 -273 62 -94 0 -128 -4 -185 -23 -109 -36 -193 -88 -271 -167 -247 -249 -244 -645 6 -896 315 -316 845 -219 1032 190 39 85 58 189 58 324 l1 112 886 491 886 491 61 -49 c221 -179 520 -194 759 -39 117 77 203 189 255 333 l26 73 4 383 3 382 193 0 c258 0 332 22 455 136 113 104 169 270 144 419 -33 195 -192 359 -382 395 -80 15 -286 12 -359 -5 -175 -41 -311 -175 -357 -350 -12 -47 -15 -123 -15 -372 l0 -313 -42 21 c-213 109 -468 84 -665 -65 -35 -26 -73 -61 -87 -78 l-23 -30 -644 285 c-354 156 -749 331 -877 388 l-234 104 6 35 c3 19 6 187 6 373 l0 337 183 0 c200 0 271 11 359 56 65 33 164 132 200 200 145 271 -6 610 -307 689 -77 20 -318 20 -392 0z`; - for (let y = 0; y < rawData.length; y += size * 4 + 1) { - rawData[y] = 0; // No filter - } - - const cx = size / 2; - const cy = size / 2; - const outerR = size * 0.42; - const innerR = size * 0.30; - const dotR = size * 0.08; - - for (let y = 0; y < size; y++) { - const rowOffset = y * (size * 4 + 1) + 1; // +1 for filter byte - for (let x = 0; x < size; x++) { - const px = rowOffset + x * 4; - const dx = x - cx; - const dy = y - cy; - const dist = Math.sqrt(dx * dx + dy * dy); - - let r = bgR, g = bgG, b = bgB, a = 255; - - // Background circle with slight rounding - const bgRadius = size * 0.46; - if (dist > bgRadius) { - a = 0; // transparent outside the circle - } - - // Ring (donut shape) - if (dist <= outerR && dist >= innerR) { - r = fgR; g = fgG; b = fgB; - } - - // Small center dot - if (dist <= dotR) { - r = fgR; g = fgG; b = fgB; - } - - // Needle/arm from center toward top-right (like a record player) - const angle = Math.atan2(dy, dx); - const targetAngle = -Math.PI / 4; // -45 degrees (top-right) - let angleDiff = Math.abs(angle - targetAngle); - if (angleDiff > Math.PI) angleDiff = 2 * Math.PI - angleDiff; - const armWidth = size * 0.025; - const armMaxDist = outerR * 0.85; - if (angleDiff < Math.atan2(armWidth, dist) && dist <= armMaxDist && dist >= dotR) { - r = fgR; g = fgG; b = fgB; - } - - // Anti-alias the outer edge of the background circle - if (dist > bgRadius - 1.5 && dist <= bgRadius + 1.5) { - const blend = Math.max(0, Math.min(1, (bgRadius + 0.75 - dist) / 1.5)); - a = Math.round(255 * blend); - } - - rawData[px] = r; - rawData[px + 1] = g; - rawData[px + 2] = b; - rawData[px + 3] = a; - } - } - - // Compress pixel data - const compressed = deflateSync(rawData, { level: 9 }); - - // Build PNG file - const chunks = []; - - // PNG signature - chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); - - // IHDR chunk - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(size, 0); // width - ihdr.writeUInt32BE(size, 4); // height - ihdr[8] = 8; // bit depth - ihdr[9] = 6; // color type: RGBA - ihdr[10] = 0; // compression - ihdr[11] = 0; // filter - ihdr[12] = 0; // interlace - chunks.push(pngChunk("IHDR", ihdr)); - - // IDAT chunk - chunks.push(pngChunk("IDAT", compressed)); - - // IEND chunk - chunks.push(pngChunk("IEND", Buffer.alloc(0))); - - return Buffer.concat(chunks); +/** + * Build an SVG string of the icon on a rounded-rect dark background. + * @param {number} size - Output pixel size + * @param {number} padding - Fraction of size to pad the icon (e.g. 0.15 = 15% each side) + * @param {number} cornerRadius - Fraction of size for corner rounding + */ +function buildIconSvg(size, { padding = 0.15, cornerRadius = 0.22 } = {}) { + const r = Math.round(size * cornerRadius); + const iconSize = Math.round(size * (1 - padding * 2)); + const offset = Math.round(size * padding); + + return ` + + + + + + +`; } -function pngChunk(type, data) { - const typeBuffer = Buffer.from(type, "ascii"); - const length = Buffer.alloc(4); - length.writeUInt32BE(data.length, 0); +/** + * Build favicon SVG — no background, just the icon on transparent. + */ +function buildFaviconSvg(size) { + return ` + + + +`; +} - const crcData = Buffer.concat([typeBuffer, data]); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(crcData) >>> 0, 0); +/** + * Create an ICO file from one or more PNG buffers. + * Modern ICO format — embeds raw PNGs. + */ +function createIco(pngBuffers) { + const count = pngBuffers.length; + const headerSize = 6; + const dirEntrySize = 16; + const dataOffset = headerSize + dirEntrySize * count; + + // ICO header + const header = Buffer.alloc(headerSize); + header.writeUInt16LE(0, 0); // reserved + header.writeUInt16LE(1, 2); // type: 1 = ICO + header.writeUInt16LE(count, 4); + + const dirEntries = []; + let currentOffset = dataOffset; + + for (const png of pngBuffers) { + const entry = Buffer.alloc(dirEntrySize); + // We'll get actual dimensions from sharp metadata, but for 32x32/16x16: + // Width/height 0 means 256 in ICO spec, otherwise actual value + entry[0] = 0; // width (will be filled) + entry[1] = 0; // height (will be filled) + entry[2] = 0; // color palette count + entry[3] = 0; // reserved + entry.writeUInt16LE(1, 4); // color planes + entry.writeUInt16LE(32, 6); // bits per pixel + entry.writeUInt32LE(png.length, 8); // size of image data + entry.writeUInt32LE(currentOffset, 12); // offset to image data + dirEntries.push({ entry, png }); + currentOffset += png.length; + } - return Buffer.concat([length, typeBuffer, data, crc]); + return Buffer.concat([header, ...dirEntries.map((d) => d.entry), ...pngBuffers]); } -function crc32(buf) { - // Standard CRC32 table - const table = new Uint32Array(256); - for (let i = 0; i < 256; i++) { - let c = i; - for (let j = 0; j < 8; j++) { - c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - } - table[i] = c; +async function main() { + mkdirSync("public/icons", { recursive: true }); + + // Generate PWA icons (with dark bg + rounded corners) + for (const size of [192, 512]) { + const svg = buildIconSvg(size); + const png = await sharp(Buffer.from(svg)).png().toBuffer(); + writeFileSync(`public/icons/icon-${size}.png`, png); + console.log(`Created public/icons/icon-${size}.png (${png.length} bytes)`); } - let crc = 0xffffffff; - for (let i = 0; i < buf.length; i++) { - crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + // Generate favicon sizes + const sizes = [16, 32, 48]; + const pngBuffers = []; + + for (const size of sizes) { + const svg = buildFaviconSvg(512); // render at full res then resize + const png = await sharp(Buffer.from(svg)) + .resize(size, size) + .png() + .toBuffer(); + pngBuffers.push(png); } - return crc ^ 0xffffffff; -} -// Generate icons -mkdirSync("public/icons", { recursive: true }); + // Set correct dimensions in ICO directory entries + const ico = createIco(pngBuffers); + // Patch dimensions into directory entries + const dirStart = 6; + for (let i = 0; i < sizes.length; i++) { + const s = sizes[i]; + ico[dirStart + i * 16] = s < 256 ? s : 0; + ico[dirStart + i * 16 + 1] = s < 256 ? s : 0; + } -const icon192 = createPNG(192); -writeFileSync("public/icons/icon-192.png", icon192); -console.log("Created public/icons/icon-192.png (%d bytes)", icon192.length); + writeFileSync("src/app/favicon.ico", ico); + console.log(`Created src/app/favicon.ico (${ico.length} bytes, ${sizes.join("+")}px)`); -const icon512 = createPNG(512); -writeFileSync("public/icons/icon-512.png", icon512); -console.log("Created public/icons/icon-512.png (%d bytes)", icon512.length); + console.log("Done! Icons generated with Noun Project 'Share Song' logo."); +} -console.log("Done! PWA icons generated."); +main().catch((err) => { + console.error("Icon generation failed:", err); + process.exit(1); +}); diff --git a/src/app/LandingClient.tsx b/src/app/LandingClient.tsx new file mode 100644 index 0000000..5b5def9 --- /dev/null +++ b/src/app/LandingClient.tsx @@ -0,0 +1,835 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { m } from 'motion/react'; +import { Globe, Mail, Plus, UserPlus } from 'lucide-react'; +import { AudioLinesIcon, type AudioLinesIconHandle } from '@/components/ui/audio-lines'; +import { FlameIcon, type FlameIconHandle } from '@/components/ui/flame'; +import { HandMetalIcon, type HandMetalIconHandle } from '@/components/ui/hand-metal'; +import { springs } from '@/lib/motion'; +import { useAlbumColors } from '@/hooks/useAlbumColors'; +import GlassDrawer from '@/components/ui/glass-drawer'; +import SpotifySetupWizard from '@/components/SpotifySetupWizard'; +import SpotifyChangesBanner from '@/components/SpotifyChangesBanner'; + +/* ------------------------------------------------------------------ */ +/* Crossfading hero video background */ +/* ------------------------------------------------------------------ */ + +const ALL_HERO_VIDEOS = [ + '/videos/hero-1.mp4', + '/videos/hero-2.mp4', + '/videos/hero-3.mp4', + '/videos/hero-4.mp4', + '/videos/hero-5.mp4', + '/videos/hero-6.mp4', + '/videos/hero-7.mp4', + '/videos/hero-8.mp4', + '/videos/hero-9.mp4', + '/videos/hero-10.mp4', + '/videos/hero-11.mp4', +]; + +/** Pick `count` random items from `arr` (Fisher-Yates partial shuffle). */ +function pickRandom(arr: T[], count: number): T[] { + const copy = [...arr]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j]!, copy[i]!]; + } + return copy.slice(0, count); +} + +const FADE_MS = 1200; +const CLIP_DURATION_MS = 5000; +const VIDEO_OPACITY = 0.45; + +/** Load a video, seek to a random point with at least `minRemaining` seconds left, and play. */ +function loadAtRandomTime(video: HTMLVideoElement, src: string, minRemaining: number) { + video.src = src; + video.load(); + + const onReady = () => { + video.removeEventListener('loadedmetadata', onReady); + const maxStart = Math.max(0, video.duration - minRemaining); + if (maxStart > 0) { + video.currentTime = Math.random() * maxStart; + } + video.play().catch(() => {}); + }; + + video.addEventListener('loadedmetadata', onReady); +} + +/** + * Two overlapping