From 70fdc821c069a067b013885a6676e8b82b8d8bdf Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:10:41 -0600 Subject: [PATCH 1/9] Migrate database from SQLite to PostgreSQL with dual-driver support Switch from better-sqlite3 to a PGlite (local dev) / node-postgres (production) dual-driver setup. Both speak native Postgres SQL so migrations work identically in both environments. - Rewrite schema from sqliteTable to pgTable (boolean, timestamptz) - Add PGlite/pg dynamic driver selection in src/db/index.ts - Add dual-driver-aware migration runner (src/db/migrate.ts) - Regenerate Drizzle migrations for PostgreSQL dialect - Archive old SQLite migrations to drizzle-sqlite-archive/ - Update drizzle.config.ts to postgresql dialect - Update seed.ts for new schema shape Co-Authored-By: Claude Opus 4.6 --- .../0000_robust_overlord.sql | 0 .../0001_slimy_hedge_knight.sql | 0 .../0002_colorful_gorgon.sql | 0 .../0003_fresh_jetstream.sql | 0 .../0004_mighty_black_widow.sql | 0 .../0005_violet_mercury.sql | 0 .../0006_rename_jam_to_playlist.sql | 0 .../0007_add_removal_delay.sql | 0 .../0008_add_liked_playlist_id.sql | 1 + .../meta/0000_snapshot.json | 683 +++++++++++++++++ .../meta/0001_snapshot.json | 705 ++++++++++++++++++ .../meta/0002_snapshot.json | 0 .../meta/0003_snapshot.json | 0 .../meta/0004_snapshot.json | 0 .../meta/0005_snapshot.json | 0 drizzle-sqlite-archive/meta/_journal.json | 62 ++ drizzle.config.ts | 10 +- drizzle/0000_mean_selene.sql | 119 +++ drizzle/0001_warm_killmonger.sql | 1 + drizzle/0002_add_vibe_name.sql | 1 + drizzle/0009_add_notification_prefs.sql | 1 + drizzle/0010_add_spotify_client_id.sql | 1 + drizzle/meta/0000_snapshot.json | 660 ++++++++++------ drizzle/meta/0001_snapshot.json | 660 ++++++++++------ drizzle/meta/_journal.json | 53 +- src/db/index.ts | 39 +- src/db/migrate.ts | 34 + src/db/schema.ts | 68 +- src/db/seed.ts | 456 ++++++----- 29 files changed, 2839 insertions(+), 715 deletions(-) rename {drizzle => drizzle-sqlite-archive}/0000_robust_overlord.sql (100%) rename {drizzle => drizzle-sqlite-archive}/0001_slimy_hedge_knight.sql (100%) rename {drizzle => drizzle-sqlite-archive}/0002_colorful_gorgon.sql (100%) rename {drizzle => drizzle-sqlite-archive}/0003_fresh_jetstream.sql (100%) rename {drizzle => drizzle-sqlite-archive}/0004_mighty_black_widow.sql (100%) rename {drizzle => drizzle-sqlite-archive}/0005_violet_mercury.sql (100%) rename {drizzle => drizzle-sqlite-archive}/0006_rename_jam_to_playlist.sql (100%) rename {drizzle => drizzle-sqlite-archive}/0007_add_removal_delay.sql (100%) create mode 100644 drizzle-sqlite-archive/0008_add_liked_playlist_id.sql create mode 100644 drizzle-sqlite-archive/meta/0000_snapshot.json create mode 100644 drizzle-sqlite-archive/meta/0001_snapshot.json rename {drizzle => drizzle-sqlite-archive}/meta/0002_snapshot.json (100%) rename {drizzle => drizzle-sqlite-archive}/meta/0003_snapshot.json (100%) rename {drizzle => drizzle-sqlite-archive}/meta/0004_snapshot.json (100%) rename {drizzle => drizzle-sqlite-archive}/meta/0005_snapshot.json (100%) create mode 100644 drizzle-sqlite-archive/meta/_journal.json create mode 100644 drizzle/0000_mean_selene.sql create mode 100644 drizzle/0001_warm_killmonger.sql create mode 100644 drizzle/0002_add_vibe_name.sql create mode 100644 drizzle/0009_add_notification_prefs.sql create mode 100644 drizzle/0010_add_spotify_client_id.sql create mode 100644 src/db/migrate.ts 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/src/db/index.ts b/src/db/index.ts index aa32434..1c36598 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,24 +1,35 @@ -import { drizzle } from 'drizzle-orm/better-sqlite3'; -import Database from 'better-sqlite3'; +import type { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; import * as schema from './schema'; -const schemaKey = Object.keys(schema).sort().join(','); +/* eslint-disable @typescript-eslint/no-require-imports */ + +// Type from node-postgres driver — PGlite produces a compatible interface. +// `import type` is erased at compile time, so PGlite is NOT bundled. +type DrizzleDb = ReturnType>; const globalForDb = globalThis as unknown as { - db: ReturnType> | undefined; - sqlite: Database.Database | undefined; - schemaKey: string | undefined; + db: DrizzleDb | undefined; }; -if (!globalForDb.sqlite) { - globalForDb.sqlite = new Database(process.env.DATABASE_PATH ?? './data/swapify.db'); - globalForDb.sqlite.pragma('journal_mode = WAL'); - globalForDb.sqlite.pragma('foreign_keys = ON'); +function createDb(): DrizzleDb { + if (process.env.DATABASE_URL) { + // Production: connect to real PostgreSQL + const { drizzle } = require('drizzle-orm/node-postgres'); + const pg = require('pg'); + const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, + }); + return drizzle(pool, { schema }); + } else { + // Local dev: use PGlite (embedded Postgres, file-based) + const { PGlite } = require('@electric-sql/pglite'); + const { drizzle } = require('drizzle-orm/pglite'); + const dataPath = process.env.DATABASE_PATH ?? './data/swapify-pg'; + const client = new PGlite(dataPath); + return drizzle(client, { schema }); + } } -if (!globalForDb.db || globalForDb.schemaKey !== schemaKey) { - globalForDb.db = drizzle(globalForDb.sqlite, { schema }); - globalForDb.schemaKey = schemaKey; -} +globalForDb.db ??= createDb(); export const db = globalForDb.db; diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..8e197b3 --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,34 @@ +import 'dotenv/config'; + +async function main() { + if (process.env.DATABASE_URL) { + // Production: node-postgres + const { drizzle } = await import('drizzle-orm/node-postgres'); + const { migrate } = await import('drizzle-orm/node-postgres/migrator'); + const pg = await import('pg'); + const pool = new pg.default.Pool({ + connectionString: process.env.DATABASE_URL, + }); + const db = drizzle(pool); + console.log('Running migrations against PostgreSQL...'); + await migrate(db, { migrationsFolder: './drizzle' }); + await pool.end(); + } else { + // Local: PGlite + const { PGlite } = await import('@electric-sql/pglite'); + const { drizzle } = await import('drizzle-orm/pglite'); + const { migrate } = await import('drizzle-orm/pglite/migrator'); + const dataPath = process.env.DATABASE_PATH ?? './data/swapify-pg'; + const client = new PGlite(dataPath); + const db = drizzle(client); + console.log('Running migrations against PGlite...'); + await migrate(db, { migrationsFolder: './drizzle' }); + await client.close(); + } + console.log('Migrations complete!'); +} + +main().catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index 36807e8..7ee67b1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,8 +1,8 @@ -import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { pgTable, text, integer, uniqueIndex, boolean, timestamp } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; // ─── Users ─────────────────────────────────────────────────────────────────── -export const users = sqliteTable('users', { +export const users = pgTable('users', { id: text('id').primaryKey(), spotifyId: text('spotify_id').notNull().unique(), displayName: text('display_name').notNull(), @@ -11,22 +11,22 @@ export const users = sqliteTable('users', { pendingEmail: text('pending_email'), emailVerifyToken: text('email_verify_token'), emailVerifyExpiresAt: integer('email_verify_expires_at'), - notifyPush: integer('notify_push').notNull().default(1), - notifyEmail: integer('notify_email').notNull().default(0), - autoNegativeReactions: integer('auto_negative_reactions').notNull().default(1), + notifyPush: boolean('notify_push').notNull().default(true), + notifyEmail: boolean('notify_email').notNull().default(false), + notificationPrefs: text('notification_prefs'), // JSON: per-type channel prefs + autoNegativeReactions: boolean('auto_negative_reactions').notNull().default(true), recentEmojis: text('recent_emojis'), // JSON array of last 3 custom emojis used + spotifyClientId: text('spotify_client_id'), // Spotify app client ID used to auth this user accessToken: text('access_token').notNull(), refreshToken: text('refresh_token').notNull(), tokenExpiresAt: integer('token_expires_at').notNull(), lastPollCursor: integer('last_poll_cursor'), lastPlaybackJson: text('last_playback_json'), // JSON: { trackId, progressMs, durationMs, capturedAt } - createdAt: integer('created_at', { mode: 'timestamp' }) - .notNull() - .$defaultFn(() => new Date()), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); // ─── Playlists ────────────────────────────────────────────────────────────── -export const playlists = sqliteTable('playlists', { +export const playlists = pgTable('playlists', { id: text('id').primaryKey(), name: text('name').notNull(), description: text('description'), @@ -41,13 +41,12 @@ export const playlists = sqliteTable('playlists', { maxTracksPerUser: integer('max_tracks_per_user'), maxTrackAgeDays: integer('max_track_age_days').notNull().default(7), removalDelay: text('removal_delay').notNull().default('immediate'), - createdAt: integer('created_at', { mode: 'timestamp' }) - .notNull() - .$defaultFn(() => new Date()), + vibeName: text('vibe_name'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); // ─── Playlist Members ─────────────────────────────────────────────────────── -export const playlistMembers = sqliteTable( +export const playlistMembers = pgTable( 'playlist_members', { id: text('id').primaryKey(), @@ -57,15 +56,14 @@ export const playlistMembers = sqliteTable( userId: text('user_id') .notNull() .references(() => users.id), - joinedAt: integer('joined_at', { mode: 'timestamp' }) - .notNull() - .$defaultFn(() => new Date()), + likedPlaylistId: text('liked_playlist_id'), + joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [uniqueIndex('playlist_members_playlist_user_idx').on(table.playlistId, table.userId)] ); // ─── Playlist Tracks ──────────────────────────────────────────────────────── -export const playlistTracks = sqliteTable( +export const playlistTracks = pgTable( 'playlist_tracks', { id: text('id').primaryKey(), @@ -82,12 +80,10 @@ export const playlistTracks = sqliteTable( addedByUserId: text('added_by_user_id') .notNull() .references(() => users.id), - addedAt: integer('added_at', { mode: 'timestamp' }) - .notNull() - .$defaultFn(() => new Date()), - removedAt: integer('removed_at', { mode: 'timestamp' }), - archivedAt: integer('archived_at', { mode: 'timestamp' }), - completedAt: integer('completed_at', { mode: 'timestamp' }), + addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(), + removedAt: timestamp('removed_at', { withTimezone: true }), + archivedAt: timestamp('archived_at', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), }, (table) => [ uniqueIndex('playlist_tracks_playlist_uri_idx').on(table.playlistId, table.spotifyTrackUri), @@ -95,7 +91,7 @@ export const playlistTracks = sqliteTable( ); // ─── Track Listens ─────────────────────────────────────────────────────────── -export const trackListens = sqliteTable( +export const trackListens = pgTable( 'track_listens', { id: text('id').primaryKey(), @@ -106,9 +102,9 @@ export const trackListens = sqliteTable( userId: text('user_id') .notNull() .references(() => users.id), - listenedAt: integer('listened_at', { mode: 'timestamp' }).notNull(), + listenedAt: timestamp('listened_at', { withTimezone: true }).notNull(), listenDurationMs: integer('listen_duration_ms'), - wasSkipped: integer('was_skipped').notNull().default(0), + wasSkipped: boolean('was_skipped').notNull().default(false), }, (table) => [ uniqueIndex('track_listens_playlist_track_user_idx').on( @@ -120,7 +116,7 @@ export const trackListens = sqliteTable( ); // ─── Track Reactions ───────────────────────────────────────────────────────── -export const trackReactions = sqliteTable( +export const trackReactions = pgTable( 'track_reactions', { id: text('id').primaryKey(), @@ -132,10 +128,8 @@ export const trackReactions = sqliteTable( .notNull() .references(() => users.id), reaction: text('reaction').notNull(), // "thumbs_up", "thumbs_down", "fire", "heart", etc. - isAuto: integer('is_auto').notNull().default(0), // 1 if auto-generated (save/skip detection) - createdAt: integer('created_at', { mode: 'timestamp' }) - .notNull() - .$defaultFn(() => new Date()), + isAuto: boolean('is_auto').notNull().default(false), // true if auto-generated (save/skip detection) + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ uniqueIndex('track_reactions_playlist_track_user_idx').on( @@ -147,7 +141,7 @@ export const trackReactions = sqliteTable( ); // ─── Email Invites ────────────────────────────────────────────────────────── -export const emailInvites = sqliteTable( +export const emailInvites = pgTable( 'email_invites', { id: text('id').primaryKey(), @@ -158,9 +152,7 @@ export const emailInvites = sqliteTable( .notNull() .references(() => users.id), recipientEmail: text('recipient_email').notNull(), - sentAt: integer('sent_at', { mode: 'timestamp' }) - .notNull() - .$defaultFn(() => new Date()), + sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ uniqueIndex('email_invites_playlist_email_idx').on(table.playlistId, table.recipientEmail), @@ -168,7 +160,7 @@ export const emailInvites = sqliteTable( ); // ─── Push Subscriptions ────────────────────────────────────────────────────── -export const pushSubscriptions = sqliteTable( +export const pushSubscriptions = pgTable( 'push_subscriptions', { id: text('id').primaryKey(), @@ -178,9 +170,7 @@ export const pushSubscriptions = sqliteTable( endpoint: text('endpoint').notNull(), p256dh: text('p256dh').notNull(), auth: text('auth').notNull(), - createdAt: integer('created_at', { mode: 'timestamp' }) - .notNull() - .$defaultFn(() => new Date()), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [uniqueIndex('push_sub_user_endpoint_idx').on(table.userId, table.endpoint)] ); diff --git a/src/db/seed.ts b/src/db/seed.ts index fd20bb6..70f3bd2 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -86,12 +86,12 @@ const insertUser = sqlite.prepare(` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); -// d.nguyen96 - the playlist owner +// d.nguyen96 - the playlist owner (avatar fetched below in async seedAllTracks) insertUser.run( ownerUserId, 'd.nguyen96', 'D. Nguyen', - null, + null, // avatar_url — populated async below null, 1, // notify_push 0, // notify_email @@ -168,175 +168,137 @@ insertMember.run(nanoid(), playlistId, myUserId, thirtyMinAgo); // ─── Playlist Tracks ─────────────────────────────────────────────────────── -const insertTrack = sqlite.prepare(` - INSERT INTO playlist_tracks (id, playlist_id, spotify_track_uri, spotify_track_id, track_name, artist_name, album_name, album_image_url, duration_ms, added_by_user_id, added_at, removed_at, archived_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -`); - -// Track 1: The Unforgiven III by Metallica - added by d.nguyen96 -insertTrack.run( - nanoid(), - playlistId, - 'spotify:track:6guXhXMAHU4QYaEsobnS6v', - '6guXhXMAHU4QYaEsobnS6v', - 'The Unforgiven III', - 'Metallica', - 'Death Magnetic', - 'https://i.scdn.co/image/ab67616d0000b273dfe44d577f07e08564ec73ed', - 466586, - ownerUserId, - twentyMinAgo, - null, - null -); - -// Track 2: Bohemian Rhapsody - added by d.nguyen96 -insertTrack.run( - nanoid(), - playlistId, - 'spotify:track:4u7EnebtmKWzUH433cf5Qv', - '4u7EnebtmKWzUH433cf5Qv', - 'Bohemian Rhapsody', - 'Queen', - 'A Night at the Opera', - 'https://i.scdn.co/image/ab67616d0000b273ce4f1737bc8a646c8c4bd25a', - 354320, - ownerUserId, - fifteenMinAgo, - null, - null -); - -// Track 3: Stairway to Heaven - added by d.nguyen96 -insertTrack.run( - nanoid(), - playlistId, - 'spotify:track:5CQ30WqJwcep0pYcV4AMNc', - '5CQ30WqJwcep0pYcV4AMNc', - 'Stairway to Heaven', - 'Led Zeppelin', - 'Led Zeppelin IV', - 'https://i.scdn.co/image/ab67616d0000b2734509204d0860cc0cc67e83dc', - 482830, - ownerUserId, - tenMinAgo, - null, - null -); - -// Track 4: Hotel California - added by d.nguyen96 -insertTrack.run( - nanoid(), - playlistId, - 'spotify:track:40riOy7x9W7GXjyGp4pjAv', - '40riOy7x9W7GXjyGp4pjAv', - 'Hotel California', - 'Eagles', - 'Hotel California', - 'https://i.scdn.co/image/ab67616d0000b2734637341b9f507521afa9a778', - 391376, - ownerUserId, - fiveMinAgo, - null, - null -); - -// Track 5: Comfortably Numb - added by d.nguyen96 -insertTrack.run( - nanoid(), - playlistId, - 'spotify:track:7HD1jkMlfB78DOCGmHbKR4', - '7HD1jkMlfB78DOCGmHbKR4', - 'Comfortably Numb', - 'Pink Floyd', - 'The Wall', - 'https://i.scdn.co/image/ab67616d0000b273f02aa309b0e1b1a9e38e03e7', - 382296, - ownerUserId, - twoMinAgo, - null, - null -); - -// Track 6: Free Bird - added by d.nguyen96 -insertTrack.run( - nanoid(), - playlistId, - 'spotify:track:0LN0ASTtcMNRYWfHMgsFSS', - '0LN0ASTtcMNRYWfHMgsFSS', - 'Free Bird', - 'Lynyrd Skynyrd', - 'Pronounced Leh-Nerd Skin-Nerd', - 'https://i.scdn.co/image/ab67616d0000b273c23400f19a8b0e7ae17cda91', - 548000, - ownerUserId, - now, // most recent - null, - null -); +const seedTracks = [ + { + uri: 'spotify:track:6guXhXMAHU4QYaEsobnS6v', + id: '6guXhXMAHU4QYaEsobnS6v', + name: 'The Unforgiven III', + artist: 'Metallica', + album: 'Death Magnetic', + image: 'https://i.scdn.co/image/ab67616d0000b273dfe44d577f07e08564ec73ed', + durationMs: 466586, + addedBy: ownerUserId, + addedAt: twentyMinAgo, + }, + { + uri: 'spotify:track:4u7EnebtmKWzUH433cf5Qv', + id: '4u7EnebtmKWzUH433cf5Qv', + name: 'Bohemian Rhapsody', + artist: 'Queen', + album: 'A Night at the Opera', + image: 'https://i.scdn.co/image/ab67616d0000b273ce4f1737bc8a646c8c4bd25a', + durationMs: 354320, + addedBy: ownerUserId, + addedAt: fifteenMinAgo, + }, + { + uri: 'spotify:track:5CQ30WqJwcep0pYcV4AMNc', + id: '5CQ30WqJwcep0pYcV4AMNc', + name: 'Stairway to Heaven', + artist: 'Led Zeppelin', + album: 'Led Zeppelin IV', + image: 'https://i.scdn.co/image/ab67616d0000b2734509204d0860cc0cc67e83dc', + durationMs: 482830, + addedBy: ownerUserId, + addedAt: tenMinAgo, + }, + { + uri: 'spotify:track:40riOy7x9W7GXjyGp4pjAv', + id: '40riOy7x9W7GXjyGp4pjAv', + name: 'Hotel California', + artist: 'Eagles', + album: 'Hotel California', + image: 'https://i.scdn.co/image/ab67616d0000b2734637341b9f507521afa9a778', + durationMs: 391376, + addedBy: ownerUserId, + addedAt: fiveMinAgo, + }, + { + uri: 'spotify:track:7HD1jkMlfB78DOCGmHbKR4', + id: '7HD1jkMlfB78DOCGmHbKR4', + name: 'Comfortably Numb', + artist: 'Pink Floyd', + album: 'The Wall', + image: 'https://i.scdn.co/image/ab67616d0000b273f02aa309b0e1b1a9e38e03e7', + durationMs: 382296, + addedBy: ownerUserId, + addedAt: twoMinAgo, + }, + { + uri: 'spotify:track:0LN0ASTtcMNRYWfHMgsFSS', + id: '0LN0ASTtcMNRYWfHMgsFSS', + name: 'Free Bird', + artist: 'Lynyrd Skynyrd', + album: 'Pronounced Leh-Nerd Skin-Nerd', + image: 'https://i.scdn.co/image/ab67616d0000b273c23400f19a8b0e7ae17cda91', + durationMs: 548000, + addedBy: ownerUserId, + addedAt: now, + }, +]; -// ─── Spotify Playlist Sync ────────────────────────────────────────────────── +// ─── Simulate Track Adds (DB + Spotify + Auto-Like) ───────────────────────── const SPOTIFY_API = 'https://api.spotify.com/v1'; const SPOTIFY_ACCOUNTS = 'https://accounts.spotify.com'; const spotifyPlaylistId = '4ZnPYsKOqPV2qP93VJDzTU'; -const seededTrackUris = [ - 'spotify:track:6guXhXMAHU4QYaEsobnS6v', - 'spotify:track:4u7EnebtmKWzUH433cf5Qv', - 'spotify:track:5CQ30WqJwcep0pYcV4AMNc', - 'spotify:track:40riOy7x9W7GXjyGp4pjAv', - 'spotify:track:7HD1jkMlfB78DOCGmHbKR4', - 'spotify:track:0LN0ASTtcMNRYWfHMgsFSS', -]; +const insertTrack = sqlite.prepare(` + INSERT INTO playlist_tracks (id, playlist_id, spotify_track_uri, spotify_track_id, track_name, artist_name, album_name, album_image_url, duration_ms, added_by_user_id, added_at, removed_at, archived_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`); -async function syncTracksToSpotify() { - if (!savedTokens) { - console.log('\n Spotify sync skipped — no saved tokens. Log in and re-run seed to sync.'); - return; +const insertReaction = sqlite.prepare(` + INSERT OR IGNORE INTO track_reactions (id, playlist_id, spotify_track_id, user_id, reaction, is_auto, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) +`); + +/** Ensure we have a valid access token, refreshing if needed. Returns null if unavailable. */ +async function getAccessToken(): Promise { + if (!savedTokens) return null; + + const nowSec = Math.floor(Date.now() / 1000); + if (savedTokens.tokenExpiresAt - nowSec >= 300) { + return savedTokens.accessToken; } - let accessToken = savedTokens.accessToken; const clientId = process.env.SPOTIFY_CLIENT_ID; + if (!clientId) { + console.log(' Token expired and SPOTIFY_CLIENT_ID not set — skipping Spotify calls.'); + return null; + } - // Refresh the token if it's expired or close to expiring - const nowSec = Math.floor(Date.now() / 1000); - if (savedTokens.tokenExpiresAt - nowSec < 300) { - if (!clientId) { - console.log('\n Spotify sync skipped — SPOTIFY_CLIENT_ID not set and token expired.'); - return; - } - console.log(' Refreshing expired token...'); - const refreshRes = await fetch(`${SPOTIFY_ACCOUNTS}/api/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: savedTokens.refreshToken, - client_id: clientId, - }), - }); - if (!refreshRes.ok) { - console.log(`\n Spotify sync skipped — token refresh failed (${refreshRes.status}).`); - return; - } - const tokenData = await refreshRes.json(); - accessToken = tokenData.access_token; - // Update the DB with the fresh token - sqlite - .prepare( - 'UPDATE users SET access_token = ?, refresh_token = ?, token_expires_at = ? WHERE id = ?' - ) - .run( - accessToken, - tokenData.refresh_token ?? savedTokens.refreshToken, - Math.floor(Date.now() / 1000) + tokenData.expires_in, - myUserId - ); + console.log(' Refreshing expired token...'); + const refreshRes = await fetch(`${SPOTIFY_ACCOUNTS}/api/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: savedTokens.refreshToken, + client_id: clientId, + }), + }); + if (!refreshRes.ok) { + console.log(` Token refresh failed (${refreshRes.status}) — skipping Spotify calls.`); + return null; } - // Fetch existing playlist tracks - console.log(`\n Syncing tracks to Spotify playlist ${spotifyPlaylistId}...`); + const tokenData = await refreshRes.json(); + savedTokens.accessToken = tokenData.access_token; + savedTokens.refreshToken = tokenData.refresh_token ?? savedTokens.refreshToken; + savedTokens.tokenExpiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in; + + sqlite + .prepare( + 'UPDATE users SET access_token = ?, refresh_token = ?, token_expires_at = ? WHERE id = ?' + ) + .run(savedTokens.accessToken, savedTokens.refreshToken, savedTokens.tokenExpiresAt, myUserId); + + return savedTokens.accessToken; +} + +/** Get the set of track URIs already in the Spotify playlist. */ +async function getExistingSpotifyUris(accessToken: string): Promise> { const existingUris = new Set(); let url: string | null = `${SPOTIFY_API}/playlists/${spotifyPlaylistId}/tracks?limit=50&fields=items(track(uri)),next`; @@ -346,8 +308,8 @@ async function syncTracksToSpotify() { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) { - console.log(` Spotify sync failed — could not read playlist (${res.status}).`); - return; + console.log(` Could not read Spotify playlist (${res.status}).`); + return existingUris; } const data = await res.json(); for (const item of data.items) { @@ -356,32 +318,166 @@ async function syncTracksToSpotify() { url = data.next; } - const missingUris = seededTrackUris.filter((uri) => !existingUris.has(uri)); + return existingUris; +} - if (missingUris.length === 0) { - console.log(' All seeded tracks already in Spotify playlist.'); - return; +/** + * Simulate adding a single track — mirrors what POST /api/playlists/[id]/tracks does: + * 1. Insert into DB + * 2. Add to Spotify playlist (if not already present) + * 3. Auto-like: check if other members have the track saved in their library + */ +async function addTrack( + track: (typeof seedTracks)[number], + accessToken: string | null, + existingSpotifyUris: Set +): Promise<{ addedToSpotify: boolean; autoLiked: boolean }> { + const result = { addedToSpotify: false, autoLiked: false }; + + // 1. Insert track into DB + insertTrack.run( + nanoid(), + playlistId, + track.uri, + track.id, + track.name, + track.artist, + track.album, + track.image, + track.durationMs, + track.addedBy, + track.addedAt, + null, + null + ); + + if (!accessToken) return result; + + // 2. Add to Spotify playlist if missing + if (!existingSpotifyUris.has(track.uri)) { + const addRes = await fetch(`${SPOTIFY_API}/playlists/${spotifyPlaylistId}/tracks`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ uris: [track.uri] }), + }); + if (addRes.ok) { + existingSpotifyUris.add(track.uri); + result.addedToSpotify = true; + } } - // Add missing tracks - const addRes = await fetch(`${SPOTIFY_API}/playlists/${spotifyPlaylistId}/tracks`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ uris: missingUris }), - }); + // 3. Auto-like: check if other members already have this track saved + // (mirrors tracks/route.ts POST — check every member except the one who added it) + const otherMemberIds = [ownerUserId, myUserId].filter((id) => id !== track.addedBy); + for (const memberId of otherMemberIds) { + // Only grayson has real tokens — skip members with fake tokens + if (memberId !== myUserId) continue; + + try { + const res = await fetch(`${SPOTIFY_API}/me/tracks/contains?ids=${track.id}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) continue; + + const [isSaved]: boolean[] = await res.json(); + if (isSaved) { + insertReaction.run( + nanoid(), + playlistId, + track.id, + memberId, + 'thumbs_up', + 1, // is_auto + new Date() + ); + result.autoLiked = true; + } + } catch { + // Token expired or rate limited — skip + } + } - if (addRes.ok) { - console.log(` Added ${missingUris.length} track(s) to Spotify playlist.`); - } else { - console.log(` Spotify sync failed — could not add tracks (${addRes.status}).`); + return result; +} + +/** Fetch a Spotify user's public profile to get their avatar URL. */ +async function fetchSpotifyAvatar(accessToken: string, spotifyId: string): Promise { + try { + const res = await fetch(`${SPOTIFY_API}/users/${encodeURIComponent(spotifyId)}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + console.log(` Could not fetch profile for ${spotifyId} (${res.status}).`); + return null; + } + const data = await res.json(); + return data.images?.at(-1)?.url ?? null; + } catch { + return null; } } -syncTracksToSpotify() - .catch((err) => console.log(` Spotify sync error: ${err.message}`)) +async function seedAllTracks() { + const accessToken = await getAccessToken(); + + // Fetch D. Nguyen's avatar from Spotify if we have tokens + if (accessToken) { + const nguynAvatar = await fetchSpotifyAvatar(accessToken, 'd.nguyen96'); + if (nguynAvatar) { + sqlite.prepare('UPDATE users SET avatar_url = ? WHERE id = ?').run(nguynAvatar, ownerUserId); + console.log(` Fetched avatar for d.nguyen96: ${nguynAvatar}`); + } + } + + if (!accessToken) { + console.log( + '\n No valid tokens — inserting tracks into DB only (no Spotify sync or auto-like).' + ); + for (const track of seedTracks) { + insertTrack.run( + nanoid(), + playlistId, + track.uri, + track.id, + track.name, + track.artist, + track.album, + track.image, + track.durationMs, + track.addedBy, + track.addedAt, + null, + null + ); + } + return; + } + + console.log(`\n Syncing to Spotify playlist ${spotifyPlaylistId}...`); + const existingSpotifyUris = await getExistingSpotifyUris(accessToken); + + console.log(' Adding tracks one by one (DB + Spotify + auto-like)...'); + let spotifyAdds = 0; + let autoLikes = 0; + + for (const track of seedTracks) { + const { addedToSpotify, autoLiked } = await addTrack(track, accessToken, existingSpotifyUris); + if (addedToSpotify) spotifyAdds++; + if (autoLiked) autoLikes++; + const flags = [addedToSpotify ? '+spotify' : '', autoLiked ? '+auto-like' : ''] + .filter(Boolean) + .join(' '); + console.log(` ${track.name}${flags ? ` (${flags})` : ''}`); + } + + console.log(` ${spotifyAdds} added to Spotify, ${autoLikes} auto-liked from library.`); +} + +seedAllTracks() + .catch((err) => console.log(` Seed error: ${err.message}`)) .finally(() => { sqlite.close(); console.log('\nSeed complete! Restart dev server to pick up changes.'); @@ -389,5 +485,5 @@ syncTracksToSpotify() console.log(` Member (grayson): ${myUserId}`); console.log(` Playlist: ${playlistId}`); console.log(` Invite code: ${inviteCode}`); - console.log(' Tracks: 6 (all added by d.nguyen96)'); + console.log(` Tracks: ${seedTracks.length} (all added by d.nguyen96)`); }); From a537af26ac7b4c9f2e0e5412ce9c144e22ee26b7 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:10:57 -0600 Subject: [PATCH 2/9] Add production hardening: security headers, rate limiting, encryption, env validation - Add Content-Security-Policy, HSTS, X-Frame-Options, and other security headers in next.config.ts - Add serverExternalPackages for PGlite/pg/pino bundling - Add in-memory token-bucket rate limiter (src/lib/rate-limit.ts) - Add AES-256-GCM token encryption at rest (src/lib/crypto.ts) - Add Zod environment validation with lazy proxy (src/env.ts) - Add Pino structured logging (src/lib/logger.ts) - Add spotify_client_id to session type Co-Authored-By: Claude Opus 4.6 --- next.config.ts | 35 +++++++++++++++--- src/env.ts | 68 +++++++++++++++++++++++++++++++++++ src/lib/crypto.ts | 69 +++++++++++++++++++++++++++++++++++ src/lib/logger.ts | 5 +++ src/lib/rate-limit.ts | 84 +++++++++++++++++++++++++++++++++++++++++++ src/lib/session.ts | 1 + 6 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 src/env.ts create mode 100644 src/lib/crypto.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/rate-limit.ts 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/src/env.ts b/src/env.ts new file mode 100644 index 0000000..a81a9f2 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + // Spotify OAuth (optional — can be passed dynamically via login flow) + SPOTIFY_CLIENT_ID: z.string().optional(), + SPOTIFY_REDIRECT_URI: z.string().optional(), + + // Session + IRON_SESSION_PASSWORD: z.string().min(32, 'IRON_SESSION_PASSWORD must be at least 32 characters'), + + // Polling + POLL_SECRET: z.string().min(16, 'POLL_SECRET must be at least 16 characters'), + POLL_INTERVAL_MS: z.coerce.number().positive().default(30000), + + // App + NEXT_PUBLIC_APP_URL: z.string().url('NEXT_PUBLIC_APP_URL must be a valid URL'), + + // Database + DATABASE_URL: z.string().optional(), + DATABASE_PATH: z.string().optional(), + + // Email (optional) + RESEND_API_KEY: z.string().optional(), + + // Push notifications (optional) + NEXT_PUBLIC_VAPID_PUBLIC_KEY: z.string().optional(), + VAPID_PRIVATE_KEY: z.string().optional(), + VAPID_SUBJECT: z.string().optional(), + + // Token encryption (optional) + TOKEN_ENCRYPTION_KEY: z.string().optional(), + + // AI — vibe name generation (optional) + ANTHROPIC_API_KEY: z.string().optional(), + + // Spotify dev mode — restricts to 5 users, conservative rate limits + SPOTIFY_DEV_MODE: z + .enum(['true', 'false']) + .optional() + .transform((v) => v === 'true'), +}); + +export type Env = z.infer; + +let _env: Env | undefined; + +export function validateEnv(): Env { + if (_env) return _env; + + const result = envSchema.safeParse(process.env); + + if (!result.success) { + const formatted = result.error.issues + .map((i) => ` - ${i.path.join('.')}: ${i.message}`) + .join('\n'); + throw new Error(`Environment validation failed:\n${formatted}`); + } + + _env = result.data; + return _env; +} + +/** Lazy proxy — validates on first property access, not at import time (safe for build). */ +export const env: Env = new Proxy({} as Env, { + get(_target, prop: string) { + return validateEnv()[prop as keyof Env]; + }, +}); diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..b1a33fe --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,69 @@ +/** + * AES-256-GCM encryption for Spotify access and refresh tokens. + * + * To generate a secure 32-byte encryption key: + * ```bash + * node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" + * ``` + * Then set TOKEN_ENCRYPTION_KEY in your .env file. + * + * If TOKEN_ENCRYPTION_KEY is not set, tokens are stored in plaintext (local dev only). + */ + +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; + +function getKey(): Buffer { + const key = process.env.TOKEN_ENCRYPTION_KEY; + if (!key) { + // No encryption key = store tokens in plaintext (local dev) + return Buffer.alloc(0); + } + const buf = Buffer.from(key, 'base64'); + if (buf.length !== 32) { + throw new Error('TOKEN_ENCRYPTION_KEY must be 32 bytes (base64-encoded)'); + } + return buf; +} + +/** + * Encrypt a plaintext string. Returns `enc:::` (all hex). + * If no encryption key is configured, returns plaintext unchanged. + */ +export function encrypt(plaintext: string): string { + const key = getKey(); + if (key.length === 0) return plaintext; + + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; +} + +/** + * Decrypt a string produced by `encrypt()`. If the string doesn't start with `enc:`, + * it's treated as unencrypted plaintext and returned as-is (migration-friendly). + */ +export function decrypt(ciphertext: string): string { + if (!ciphertext.startsWith('enc:')) return ciphertext; + + const key = getKey(); + if (key.length === 0) { + throw new Error('TOKEN_ENCRYPTION_KEY required to decrypt tokens'); + } + + const parts = ciphertext.split(':'); + if (parts.length !== 4) throw new Error('Invalid encrypted token format'); + + const iv = Buffer.from(parts[1]!, 'hex'); + const authTag = Buffer.from(parts[2]!, 'hex'); + const encrypted = Buffer.from(parts[3]!, 'hex'); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final('utf8'); +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..66f48c8 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,5 @@ +import pino from 'pino'; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? (process.env.NODE_ENV === 'production' ? 'info' : 'debug'), +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..adf9c00 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,84 @@ +import { NextResponse } from 'next/server'; +import { isSpotifyDevMode } from '@/lib/spotify-config'; + +interface RateLimitEntry { + tokens: number; + lastRefill: number; +} + +interface RateLimitConfig { + /** Max tokens (requests) in the bucket */ + maxTokens: number; + /** Tokens added per second */ + refillRate: number; +} + +const buckets = new Map(); + +// Clean up old entries every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of buckets) { + if (now - entry.lastRefill > 600_000) { + buckets.delete(key); + } + } +}, 300_000); + +/** + * Check rate limit for a given key. Returns null if allowed, or a NextResponse 429 if blocked. + */ +export function checkRateLimit(key: string, config: RateLimitConfig): NextResponse | null { + const now = Date.now(); + let entry = buckets.get(key); + + if (!entry) { + entry = { tokens: config.maxTokens - 1, lastRefill: now }; + buckets.set(key, entry); + return null; + } + + // Refill tokens based on elapsed time + const elapsed = (now - entry.lastRefill) / 1000; + entry.tokens = Math.min(config.maxTokens, entry.tokens + elapsed * config.refillRate); + entry.lastRefill = now; + + if (entry.tokens < 1) { + const retryAfter = Math.ceil((1 - entry.tokens) / config.refillRate); + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { + status: 429, + headers: { 'Retry-After': retryAfter.toString() }, + } + ); + } + + entry.tokens -= 1; + return null; +} + +// Pre-configured rate limit profiles +// Dev mode uses stricter limits to stay within Spotify's lower API budget +function devMode() { + return isSpotifyDevMode(); +} + +export const RATE_LIMITS = { + /** General API: 60 req/min (dev: 20 req/min) per user */ + get api(): RateLimitConfig { + return devMode() ? { maxTokens: 20, refillRate: 0.33 } : { maxTokens: 60, refillRate: 1 }; + }, + /** Search: 30 req/min (dev: 10 req/min) per user */ + get search(): RateLimitConfig { + return devMode() ? { maxTokens: 10, refillRate: 0.17 } : { maxTokens: 30, refillRate: 0.5 }; + }, + /** Mutations (create playlist, add track): 20 req/min (dev: 8 req/min) per user */ + get mutation(): RateLimitConfig { + return devMode() ? { maxTokens: 8, refillRate: 0.13 } : { maxTokens: 20, refillRate: 0.33 }; + }, + /** Email invites: 10 per hour per user (unchanged in dev mode) */ + invite: { maxTokens: 10, refillRate: 10 / 3600 } as RateLimitConfig, + /** Unauthenticated endpoints: 20 req/min per IP */ + public: { maxTokens: 20, refillRate: 0.33 } as RateLimitConfig, +}; diff --git a/src/lib/session.ts b/src/lib/session.ts index aad2cab..913ba1e 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -6,6 +6,7 @@ export interface SessionData { displayName?: string; avatarUrl?: string; codeVerifier?: string; + spotifyClientId?: string; returnTo?: string; } From 9305323f217965b4db083c77dd4bff9f8b7c1e37 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:11:16 -0600 Subject: [PATCH 3/9] Add Spotify dev mode, enhanced polling, and API improvements - Add Spotify dev mode config (5-user cap, conservative rate limits, longer poll intervals, global API budget tracker) - Overhaul polling system with listen detection, sync auditing, and Spotify change detection - Add rate limiting to all API routes (per-profile token bucket) - Add /api/health endpoint for Fly.io health checks - Add /api/auth/me, /api/profile/preferences, /api/email/unsubscribe - Add liked-playlist endpoint for per-user liked tracks - Enhance track routes with vibe-sort and batch operations - Add vibe name generation, notification preferences, push client utils - Update auth flow with dev mode user cap and token encryption Co-Authored-By: Claude Opus 4.6 --- src/app/api/auth/callback/route.ts | 40 +- src/app/api/auth/login/route.ts | 14 +- src/app/api/auth/me/route.ts | 10 + src/app/api/email/unsubscribe/route.ts | 41 ++ src/app/api/health/route.ts | 16 + .../playlists/[playlistId]/invite/route.ts | 5 + .../api/playlists/[playlistId]/join/route.ts | 14 + .../[playlistId]/liked-playlist/route.ts | 116 +++++ .../playlists/[playlistId]/reactions/route.ts | 27 +- src/app/api/playlists/[playlistId]/route.ts | 39 +- .../playlists/[playlistId]/tracks/route.ts | 165 ++++++- .../[playlistId]/tracks/sync/route.ts | 38 +- src/app/api/playlists/resolve/route.ts | 5 + src/app/api/playlists/route.ts | 18 +- src/app/api/profile/preferences/route.ts | 51 ++ src/app/api/spotify/play/route.ts | 7 +- src/app/api/spotify/search/route.ts | 7 +- src/lib/email.ts | 29 +- src/lib/notification-prefs.ts | 63 +++ src/lib/notifications.ts | 31 +- src/lib/polling.ts | 459 +++++++++++++----- src/lib/push-client.ts | 39 ++ src/lib/spotify-config.ts | 129 +++++ src/lib/spotify.ts | 116 ++++- src/lib/vibe-name.ts | 105 ++++ src/lib/vibe-sort.ts | 19 +- 26 files changed, 1377 insertions(+), 226 deletions(-) create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/email/unsubscribe/route.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/api/playlists/[playlistId]/liked-playlist/route.ts create mode 100644 src/app/api/profile/preferences/route.ts create mode 100644 src/lib/notification-prefs.ts create mode 100644 src/lib/push-client.ts create mode 100644 src/lib/spotify-config.ts create mode 100644 src/lib/vibe-name.ts diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index ffbce90..8380599 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -3,11 +3,14 @@ import { cookies } from 'next/headers'; import { getIronSession } from 'iron-session'; import { SessionData, sessionOptions } from '@/lib/session'; import { getSpotifyProfile } from '@/lib/spotify'; +import { encrypt } from '@/lib/crypto'; import { db } from '@/db'; import { users } from '@/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { generateId } from '@/lib/utils'; import type { SpotifyTokenResponse } from '@/types/spotify'; +import { logger } from '@/lib/logger'; +import { spotifyConfig } from '@/lib/spotify-config'; export async function GET(request: NextRequest) { const baseUrl = process.env.NEXT_PUBLIC_APP_URL || request.url; @@ -25,6 +28,12 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL('/login?error=no_verifier', baseUrl)); } + if (!session.spotifyClientId) { + return NextResponse.redirect(new URL('/login?error=no_client_id', baseUrl)); + } + + const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`; + // Exchange code for tokens const tokenRes = await fetch('https://accounts.spotify.com/api/token', { method: 'POST', @@ -32,15 +41,15 @@ export async function GET(request: NextRequest) { body: new URLSearchParams({ grant_type: 'authorization_code', code, - redirect_uri: process.env.SPOTIFY_REDIRECT_URI!, - client_id: process.env.SPOTIFY_CLIENT_ID!, + redirect_uri: redirectUri, + client_id: session.spotifyClientId, code_verifier: session.codeVerifier, }), }); if (!tokenRes.ok) { const err = await tokenRes.text(); - console.error('Token exchange failed:', err); + logger.error(`Token exchange failed: ${err}`); return NextResponse.redirect(new URL('/login?error=token_exchange', baseUrl)); } @@ -55,6 +64,16 @@ export async function GET(request: NextRequest) { where: eq(users.spotifyId, profile.id), }); + // Enforce dev mode user cap (Spotify allows max 5 users in dev mode) + if (spotifyConfig.devMode && !existingUser) { + const result = await db.select({ count: sql`count(*)` }).from(users); + const userCount = result[0]?.count ?? 0; + if (userCount >= spotifyConfig.maxUsers) { + logger.warn(`[Swapify] Dev mode user limit reached (${userCount}/${spotifyConfig.maxUsers})`); + return NextResponse.redirect(new URL('/login?error=dev_mode_user_limit', baseUrl)); + } + } + let userId: string; if (existingUser) { userId = existingUser.id; @@ -63,9 +82,12 @@ export async function GET(request: NextRequest) { .set({ displayName: profile.display_name, avatarUrl: avatarUrl, - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token ?? existingUser.refreshToken, + accessToken: encrypt(tokenData.access_token), + refreshToken: tokenData.refresh_token + ? encrypt(tokenData.refresh_token) + : existingUser.refreshToken, tokenExpiresAt: Math.floor(Date.now() / 1000) + tokenData.expires_in, + spotifyClientId: session.spotifyClientId, }) .where(eq(users.id, userId)); } else { @@ -75,9 +97,10 @@ export async function GET(request: NextRequest) { spotifyId: profile.id, displayName: profile.display_name, avatarUrl: avatarUrl, - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token!, + accessToken: encrypt(tokenData.access_token), + refreshToken: encrypt(tokenData.refresh_token!), tokenExpiresAt: Math.floor(Date.now() / 1000) + tokenData.expires_in, + spotifyClientId: session.spotifyClientId, }); } @@ -88,6 +111,7 @@ export async function GET(request: NextRequest) { session.displayName = profile.display_name; session.avatarUrl = avatarUrl ?? undefined; session.codeVerifier = undefined; + session.spotifyClientId = undefined; session.returnTo = undefined; await session.save(); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 3a4c226..130513a 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -23,19 +23,27 @@ async function generateCodeChallenge(verifier: string): Promise { export async function GET(request: NextRequest) { const returnTo = request.nextUrl.searchParams.get('returnTo'); + const clientId = request.nextUrl.searchParams.get('clientId') ?? process.env.SPOTIFY_CLIENT_ID; + + if (!clientId) { + return NextResponse.redirect(new URL('/login?error=no_client_id', request.url)); + } + + const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`; const codeVerifier = generateRandomString(64); const codeChallenge = await generateCodeChallenge(codeVerifier); const state = generateRandomString(16); - // Store code verifier in session const cookieStore = await cookies(); const session = await getIronSession(cookieStore, sessionOptions); session.codeVerifier = codeVerifier; + session.spotifyClientId = clientId; if (returnTo) session.returnTo = returnTo; await session.save(); const scopes = [ + 'user-read-private', 'user-read-recently-played', 'playlist-read-private', 'playlist-read-collaborative', @@ -50,9 +58,9 @@ export async function GET(request: NextRequest) { const params = new URLSearchParams({ response_type: 'code', - client_id: process.env.SPOTIFY_CLIENT_ID!, + client_id: clientId, scope: scopes, - redirect_uri: process.env.SPOTIFY_REDIRECT_URI!, + redirect_uri: redirectUri, state, code_challenge_method: 'S256', code_challenge: codeChallenge, diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..441e597 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; + +export async function GET() { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + return NextResponse.json({ id: user.id, displayName: user.displayName }); +} diff --git a/src/app/api/email/unsubscribe/route.ts b/src/app/api/email/unsubscribe/route.ts new file mode 100644 index 0000000..7313018 --- /dev/null +++ b/src/app/api/email/unsubscribe/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { users } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +// GET /api/email/unsubscribe?uid= — browser unsubscribe link +export async function GET(request: NextRequest) { + const userId = request.nextUrl.searchParams.get('uid'); + if (!userId) { + return new NextResponse('Invalid unsubscribe link', { status: 400 }); + } + + await db.update(users).set({ notifyEmail: false }).where(eq(users.id, userId)); + + return new NextResponse( + `Unsubscribed + +

Swapify

+

You have been unsubscribed from email notifications.

+

You can re-enable email notifications in your Swapify profile settings.

+`, + { headers: { 'Content-Type': 'text/html' } } + ); +} + +// POST /api/email/unsubscribe — RFC 8058 one-click unsubscribe from email clients +export async function POST(request: NextRequest) { + const userId = + request.nextUrl.searchParams.get('uid') ?? + (await request + .formData() + .then((f) => f.get('List-Unsubscribe') as string | null) + .catch(() => null)); + + if (!userId) { + return new NextResponse('Invalid', { status: 400 }); + } + + await db.update(users).set({ notifyEmail: false }).where(eq(users.id, userId)); + return new NextResponse('Unsubscribed', { status: 200 }); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..acf3cbf --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,16 @@ +import { db } from '@/db'; +import { users } from '@/db/schema'; +import { NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; + +export async function GET() { + try { + // Simple DB query to verify connectivity + await db.select().from(users).limit(1); + + return NextResponse.json({ status: 'ok' }, { status: 200 }); + } catch (error) { + logger.error({ error }, 'Health check failed'); + return NextResponse.json({ status: 'error', message: 'Database unreachable' }, { status: 503 }); + } +} diff --git a/src/app/api/playlists/[playlistId]/invite/route.ts b/src/app/api/playlists/[playlistId]/invite/route.ts index b3cc942..ddcf55a 100644 --- a/src/app/api/playlists/[playlistId]/invite/route.ts +++ b/src/app/api/playlists/[playlistId]/invite/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth'; +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'; import { db } from '@/db'; import { playlists, playlistMembers, emailInvites } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; @@ -14,6 +15,10 @@ export async function POST( { params }: { params: Promise<{ playlistId: string }> } ) { const user = await requireAuth(); + + const limited = checkRateLimit(`invite:${user.id}`, RATE_LIMITS.invite); + if (limited) return limited; + const { playlistId } = await params; const body = await request.json(); diff --git a/src/app/api/playlists/[playlistId]/join/route.ts b/src/app/api/playlists/[playlistId]/join/route.ts index 5a56429..c48cf38 100644 --- a/src/app/api/playlists/[playlistId]/join/route.ts +++ b/src/app/api/playlists/[playlistId]/join/route.ts @@ -71,5 +71,19 @@ export async function POST( await db.update(playlists).set({ name: newName }).where(eq(playlists.id, playlistId)); } + // Notify existing members that someone joined + import('@/lib/notifications').then(({ notifyPlaylistMembers }) => { + notifyPlaylistMembers( + playlistId, + user.id, + { + title: 'New member joined', + body: `${user.displayName} joined "${playlist.name}"`, + url: `${process.env.NEXT_PUBLIC_APP_URL}/playlist/${playlistId}`, + }, + 'memberJoined' + ); + }); + return NextResponse.json({ success: true, playlistId }); } diff --git a/src/app/api/playlists/[playlistId]/liked-playlist/route.ts b/src/app/api/playlists/[playlistId]/liked-playlist/route.ts new file mode 100644 index 0000000..4fd4388 --- /dev/null +++ b/src/app/api/playlists/[playlistId]/liked-playlist/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { db } from '@/db'; +import { playlists, playlistMembers, playlistTracks, trackReactions } from '@/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { createPlaylist, addItemsToPlaylist, getPlaylistDetails } from '@/lib/spotify'; + +// POST /api/playlists/[playlistId]/liked-playlist — create or sync liked Spotify playlist +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ playlistId: string }> } +) { + const user = await requireAuth(); + const { playlistId } = await params; + + // Verify membership + const membership = await db.query.playlistMembers.findFirst({ + where: and(eq(playlistMembers.playlistId, playlistId), eq(playlistMembers.userId, user.id)), + }); + if (!membership) { + return NextResponse.json({ error: 'Not a member' }, { status: 403 }); + } + + const playlist = await db.query.playlists.findFirst({ + where: eq(playlists.id, playlistId), + }); + if (!playlist) { + return NextResponse.json({ error: 'Playlist not found' }, { status: 404 }); + } + + // If user already has a liked playlist, verify it still exists + if (membership.likedPlaylistId) { + try { + await getPlaylistDetails(user.id, membership.likedPlaylistId); + // Playlist exists — return it + return NextResponse.json({ + spotifyPlaylistId: membership.likedPlaylistId, + spotifyPlaylistUrl: `https://open.spotify.com/playlist/${membership.likedPlaylistId}`, + }); + } catch { + // Playlist was deleted — clear and recreate + await db + .update(playlistMembers) + .set({ likedPlaylistId: null }) + .where(eq(playlistMembers.id, membership.id)); + } + } + + // Create new Spotify playlist under this user's account + const spotifyPlaylist = await createPlaylist( + user.id, + `${playlist.name} Likes`, + `Tracks I liked from ${playlist.name} on Swapify`, + { collaborative: false } + ); + + // Store on membership + await db + .update(playlistMembers) + .set({ likedPlaylistId: spotifyPlaylist.id }) + .where(eq(playlistMembers.id, membership.id)); + + // Initial population: get all liked track URIs + const likedReactions = await db.query.trackReactions.findMany({ + where: and( + eq(trackReactions.playlistId, playlistId), + eq(trackReactions.userId, user.id), + eq(trackReactions.reaction, 'thumbs_up') + ), + }); + + if (likedReactions.length > 0) { + const likedTrackIds = likedReactions.map((r) => r.spotifyTrackId); + const tracks = await db.query.playlistTracks.findMany({ + where: eq(playlistTracks.playlistId, playlistId), + }); + const uris = [ + ...new Set( + tracks.filter((t) => likedTrackIds.includes(t.spotifyTrackId)).map((t) => t.spotifyTrackUri) + ), + ]; + if (uris.length > 0) { + for (let i = 0; i < uris.length; i += 100) { + await addItemsToPlaylist(user.id, spotifyPlaylist.id, uris.slice(i, i + 100)); + } + } + } + + return NextResponse.json({ + spotifyPlaylistId: spotifyPlaylist.id, + spotifyPlaylistUrl: `https://open.spotify.com/playlist/${spotifyPlaylist.id}`, + }); +} + +// DELETE /api/playlists/[playlistId]/liked-playlist — stop syncing +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ playlistId: string }> } +) { + const user = await requireAuth(); + const { playlistId } = await params; + + const membership = await db.query.playlistMembers.findFirst({ + where: and(eq(playlistMembers.playlistId, playlistId), eq(playlistMembers.userId, user.id)), + }); + if (!membership) { + return NextResponse.json({ error: 'Not a member' }, { status: 403 }); + } + + await db + .update(playlistMembers) + .set({ likedPlaylistId: null }) + .where(eq(playlistMembers.id, membership.id)); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/playlists/[playlistId]/reactions/route.ts b/src/app/api/playlists/[playlistId]/reactions/route.ts index 94e7eab..8f8856f 100644 --- a/src/app/api/playlists/[playlistId]/reactions/route.ts +++ b/src/app/api/playlists/[playlistId]/reactions/route.ts @@ -49,10 +49,10 @@ export async function POST( }); if (existing) { - // Update existing reaction + // Update existing reaction — no notification (they already got one) await db .update(trackReactions) - .set({ reaction, isAuto: 0 }) + .set({ reaction, isAuto: false }) .where(eq(trackReactions.id, existing.id)); } else { // Insert new reaction @@ -62,8 +62,29 @@ export async function POST( spotifyTrackId, userId: user.id, reaction, - isAuto: 0, + isAuto: false, }); + + // Only notify on first reaction, not on changes + if (track && track.addedByUserId !== user.id) { + const label = + reaction === 'thumbs_up' + ? 'liked' + : reaction === 'thumbs_down' + ? 'passed on' + : `reacted ${reaction} to`; + import('@/lib/notifications').then(({ notify }) => { + notify( + track.addedByUserId, + { + title: 'New reaction', + body: `${user.displayName} ${label} "${track.trackName}"`, + url: `${process.env.NEXT_PUBLIC_APP_URL}/playlist/${playlistId}`, + }, + 'reactions' + ); + }); + } } // Update user's recent emojis if it's a custom emoji (not thumbs_up/thumbs_down) diff --git a/src/app/api/playlists/[playlistId]/route.ts b/src/app/api/playlists/[playlistId]/route.ts index c8a6943..b6b322b 100644 --- a/src/app/api/playlists/[playlistId]/route.ts +++ b/src/app/api/playlists/[playlistId]/route.ts @@ -3,7 +3,7 @@ import { requireAuth } from '@/lib/auth'; import { db } from '@/db'; import { playlists, playlistMembers } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; -import { updatePlaylistDetails, uploadPlaylistImage, createPlaylist } from '@/lib/spotify'; +import { updatePlaylistDetails, uploadPlaylistImage, getPlaylistDetails } from '@/lib/spotify'; import { VALID_REMOVAL_DELAYS } from '@/lib/utils'; // GET /api/playlists/[playlistId] — playlist detail @@ -65,41 +65,12 @@ export async function PATCH( } const body = await request.json(); - const { - name, - description, - imageBase64, - archiveThreshold, - maxTracksPerUser, - maxTrackAgeDays, - removalDelay, - } = body; - - const validThresholds = ['none', 'no_dislikes', 'at_least_one_like', 'universally_liked']; + const { name, description, imageBase64, maxTracksPerUser, maxTrackAgeDays, removalDelay } = body; const updates: Partial = {}; if (name !== undefined) updates.name = name; if (description !== undefined) updates.description = description; - // Handle archive threshold - if (archiveThreshold !== undefined) { - if (!validThresholds.includes(archiveThreshold)) { - return NextResponse.json({ error: 'Invalid archive threshold' }, { status: 400 }); - } - updates.archiveThreshold = archiveThreshold; - - // Auto-create Keepers playlist when enabling archiving for the first time - if (archiveThreshold !== 'none' && !playlist.archivePlaylistId) { - const keepersPlaylist = await createPlaylist( - user.id, - `${playlist.name} Keepers`, - `Favorite tracks from ${playlist.name}`, - { collaborative: false } - ); - updates.archivePlaylistId = keepersPlaylist.id; - } - } - // Handle max tracks per user if (maxTracksPerUser !== undefined) { if (maxTracksPerUser === null) { @@ -139,10 +110,12 @@ export async function PATCH( await updatePlaylistDetails(user.id, playlist.spotifyPlaylistId, spotifyUpdates); } - // Upload cover image if provided + // Upload cover image if provided, then fetch the CDN URL from Spotify if (imageBase64) { await uploadPlaylistImage(user.id, playlist.spotifyPlaylistId, imageBase64); - updates.imageUrl = `data:image/jpeg;base64,${imageBase64.substring(0, 50)}...`; + // Spotify processes the image async — fetch the CDN URL it generates + const details = await getPlaylistDetails(user.id, playlist.spotifyPlaylistId); + updates.imageUrl = details.imageUrl; } if (Object.keys(updates).length > 0) { diff --git a/src/app/api/playlists/[playlistId]/tracks/route.ts b/src/app/api/playlists/[playlistId]/tracks/route.ts index 1a4bb66..4c1c92f 100644 --- a/src/app/api/playlists/[playlistId]/tracks/route.ts +++ b/src/app/api/playlists/[playlistId]/tracks/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth'; +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'; import { db } from '@/db'; import { playlists, @@ -9,7 +10,12 @@ import { trackReactions, } from '@/db/schema'; import { eq, and, isNull, isNotNull, sql } from 'drizzle-orm'; -import { addItemsToPlaylist, getPlaylistItems } from '@/lib/spotify'; +import { + addItemsToPlaylist, + getPlaylistItems, + checkSavedTracks, + isRateLimited, +} from '@/lib/spotify'; import { generateId } from '@/lib/utils'; // GET /api/playlists/[playlistId]/tracks — tracks with listen progress + reactions @@ -66,10 +72,7 @@ export async function GET( // Get removed tracks (previously played) const removedTracks = await db.query.playlistTracks.findMany({ - where: and( - eq(playlistTracks.playlistId, playlistId), - isNotNull(playlistTracks.removedAt) - ), + where: and(eq(playlistTracks.playlistId, playlistId), isNotNull(playlistTracks.removedAt)), with: { addedBy: true }, orderBy: playlistTracks.addedAt, }); @@ -93,6 +96,29 @@ export async function GET( avatarUrl: m.user.avatarUrl, })); + // Parse active playback for each member (for "now listening" indicators) + const PLAYBACK_FRESHNESS_MS = 90_000; // 90s = 3 poll cycles + const now = Date.now(); + interface PlaybackInfo { + trackId: string; + progressMs: number; + durationMs: number; + capturedAt: number; + } + const memberPlayback = new Map(); // userId -> playback snapshot + for (const m of members) { + if (m.user.lastPlaybackJson) { + try { + const snap = JSON.parse(m.user.lastPlaybackJson) as PlaybackInfo; + if (snap.trackId && now - snap.capturedAt < PLAYBACK_FRESHNESS_MS) { + memberPlayback.set(m.user.id, snap); + } + } catch { + // Invalid JSON, skip + } + } + } + const activeTracks = tracks.map((track) => { const requiredListeners = memberList.filter((m) => m.id !== track.addedByUserId); const trackListenRecords = listens.filter((l) => l.spotifyTrackId === track.spotifyTrackId); @@ -100,10 +126,11 @@ export async function GET( const progress = requiredListeners.map((member) => { const listen = trackListenRecords.find((l) => l.userId === member.id); + const reaction = trackReactionRecords.find((r) => r.userId === member.id); return { ...member, - hasListened: !!listen, - listenedAt: listen?.listenedAt ?? null, + hasListened: !!listen || !!reaction, + listenedAt: listen?.listenedAt ?? reaction?.createdAt ?? null, }; }); @@ -130,16 +157,59 @@ export async function GET( displayName: r.user.displayName, avatarUrl: r.user.avatarUrl, reaction: r.reaction, - isAuto: !!r.isAuto, + isAuto: r.isAuto, createdAt: r.createdAt, })), + activeListeners: memberList + .filter((m) => { + const snap = memberPlayback.get(m.id); + if (!snap || snap.trackId !== track.spotifyTrackId) return false; + // Estimate current position — stop showing if past track duration + const estimatedMs = snap.progressMs + (now - snap.capturedAt); + return estimatedMs < snap.durationMs; + }) + .map((m) => { + const snap = memberPlayback.get(m.id)!; + return { + ...m, + progressMs: snap.progressMs, + durationMs: snap.durationMs, + capturedAt: snap.capturedAt, + }; + }), }; }); // Previously played tracks const previousTracks = removedTracks.map((track) => ({ - id: track.id, + id: track.id, + spotifyTrackId: track.spotifyTrackId, + trackName: track.trackName, + artistName: track.artistName, + albumImageUrl: track.albumImageUrl, + addedBy: { + id: track.addedBy.id, + displayName: track.addedBy.displayName, + avatarUrl: track.addedBy.avatarUrl, + }, + addedAt: track.addedAt, + removedAt: track.removedAt, + archivedAt: track.archivedAt, + })); + + // Liked tracks: all tracks where current user has thumbs_up reaction + const userLikedTrackIds = new Set( + reactions + .filter((r) => r.userId === user.id && r.reaction === 'thumbs_up') + .map((r) => r.spotifyTrackId) + ); + + const allDbTracks = [...tracks, ...removedTracks]; + const likedTracks = allDbTracks + .filter((t) => userLikedTrackIds.has(t.spotifyTrackId)) + .map((track) => ({ spotifyTrackId: track.spotifyTrackId, + spotifyTrackUri: track.spotifyTrackUri, trackName: track.trackName, artistName: track.artistName, albumImageUrl: track.albumImageUrl, @@ -150,13 +220,41 @@ export async function GET( }, addedAt: track.addedAt, removedAt: track.removedAt, - archivedAt: track.archivedAt, + isActive: !track.removedAt, })); + // Outcast tracks: removed tracks where current user does NOT have thumbs_up + const outcastTracks = removedTracks + .filter((t) => !userLikedTrackIds.has(t.spotifyTrackId)) + .map((track) => { + const userReaction = reactions.find( + (r) => r.userId === user.id && r.spotifyTrackId === track.spotifyTrackId + ); + return { + spotifyTrackId: track.spotifyTrackId, + spotifyTrackUri: track.spotifyTrackUri, + trackName: track.trackName, + artistName: track.artistName, + albumImageUrl: track.albumImageUrl, + addedBy: { + id: track.addedBy.id, + displayName: track.addedBy.displayName, + avatarUrl: track.addedBy.avatarUrl, + }, + addedAt: track.addedAt, + removedAt: track.removedAt, + reaction: userReaction?.reaction ?? null, + }; + }); + return NextResponse.json({ tracks: activeTracks, previousTracks, members: memberList, + likedTracks, + outcastTracks, + likedPlaylistId: membership.likedPlaylistId ?? null, + vibeName: playlist?.vibeName ?? null, }); } @@ -166,6 +264,10 @@ export async function POST( { params }: { params: Promise<{ playlistId: string }> } ) { const user = await requireAuth(); + + const limited = checkRateLimit(`mutation:${user.id}`, RATE_LIMITS.mutation); + if (limited) return limited; + const { playlistId } = await params; const membership = await db.query.playlistMembers.findFirst({ @@ -241,7 +343,7 @@ export async function POST( } catch (err) { console.error('Spotify addItemsToPlaylist failed:', err); return NextResponse.json( - { error: `Spotify error: ${err instanceof Error ? err.message : 'unknown'}` }, + { error: 'Unable to add track to playlist. Please try again.' }, { status: 502 } ); } @@ -263,11 +365,16 @@ export async function POST( // Notify other members import('@/lib/notifications').then(({ notifyPlaylistMembers }) => { - notifyPlaylistMembers(playlistId, user.id, { - title: 'New track added', - body: `${user.displayName} added "${trackName}" by ${artistName}`, - url: `${process.env.NEXT_PUBLIC_APP_URL}/playlist/${playlistId}`, - }); + notifyPlaylistMembers( + playlistId, + user.id, + { + title: 'New track added', + body: `${user.displayName} added "${trackName}" by ${artistName}`, + url: `${process.env.NEXT_PUBLIC_APP_URL}/playlist/${playlistId}`, + }, + 'newTrack' + ); }); // Auto-sort playlist by vibe (fire-and-forget) @@ -275,5 +382,31 @@ export async function POST( vibeSort(playlistId).catch(() => {}); }); + // Auto-like: check if other members already have this track saved in their library + (async () => { + try { + const { setAutoReaction } = await import('@/lib/polling'); + const otherMembers = ( + await db.query.playlistMembers.findMany({ + where: eq(playlistMembers.playlistId, playlistId), + }) + ).filter((m) => m.userId !== user.id); + + for (const member of otherMembers) { + if (isRateLimited()) break; + try { + const [isSaved] = await checkSavedTracks(member.userId, [spotifyTrackId]); + if (isSaved) { + await setAutoReaction(playlistId, spotifyTrackId, member.userId, 'thumbs_up'); + } + } catch { + // Token expired or rate limited — skip this member + } + } + } catch (err) { + console.error('[Swapify] Auto-like library check failed:', err); + } + })(); + return NextResponse.json({ id: trackId, success: true }); } diff --git a/src/app/api/playlists/[playlistId]/tracks/sync/route.ts b/src/app/api/playlists/[playlistId]/tracks/sync/route.ts index 87cbcf3..0d8510b 100644 --- a/src/app/api/playlists/[playlistId]/tracks/sync/route.ts +++ b/src/app/api/playlists/[playlistId]/tracks/sync/route.ts @@ -3,7 +3,7 @@ import { requireAuth } from '@/lib/auth'; import { db } from '@/db'; import { playlists, playlistMembers, playlistTracks } from '@/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; -import { getPlaylistItems } from '@/lib/spotify'; +import { getPlaylistItems, getPlaylistDetails } from '@/lib/spotify'; import { generateId } from '@/lib/utils'; // POST /api/playlists/[playlistId]/tracks/sync — sync playlist items from Spotify @@ -29,8 +29,30 @@ export async function POST( return NextResponse.json({ error: 'Playlist not found' }, { status: 404 }); } - // Fetch current playlist items from Spotify - const spotifyItems = await getPlaylistItems(playlist.ownerId, playlist.spotifyPlaylistId); + // Fetch playlist metadata + items from Spotify in parallel + const [spotifyDetails, spotifyItems] = await Promise.all([ + getPlaylistDetails(playlist.ownerId, playlist.spotifyPlaylistId), + getPlaylistItems(playlist.ownerId, playlist.spotifyPlaylistId), + ]); + + // Check if metadata changed on Spotify and update local DB + const metadataChanges: { name?: string; description?: string | null; imageUrl?: string | null } = + {}; + + if (spotifyDetails.name !== playlist.name) { + metadataChanges.name = spotifyDetails.name; + } + if (spotifyDetails.description !== playlist.description) { + metadataChanges.description = spotifyDetails.description; + } + // Always prefer Spotify's CDN URL over a local data URL + if (spotifyDetails.imageUrl && spotifyDetails.imageUrl !== playlist.imageUrl) { + metadataChanges.imageUrl = spotifyDetails.imageUrl; + } + + if (Object.keys(metadataChanges).length > 0) { + await db.update(playlists).set(metadataChanges).where(eq(playlists.id, playlistId)); + } // Get our active tracks const localTracks = await db.query.playlistTracks.findMany({ @@ -77,12 +99,16 @@ export async function POST( } } - // Auto-sort playlist by vibe if new tracks were added (fire-and-forget) - if (added > 0) { + // Auto-sort playlist by vibe if tracks changed (fire-and-forget) + if (added > 0 || removed > 0) { import('@/lib/vibe-sort').then(({ vibeSort }) => { vibeSort(playlistId).catch(() => {}); }); } - return NextResponse.json({ added, removed }); + return NextResponse.json({ + added, + removed, + ...(Object.keys(metadataChanges).length > 0 && { metadata: metadataChanges }), + }); } diff --git a/src/app/api/playlists/resolve/route.ts b/src/app/api/playlists/resolve/route.ts index 58fa096..cccb6c5 100644 --- a/src/app/api/playlists/resolve/route.ts +++ b/src/app/api/playlists/resolve/route.ts @@ -1,10 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'; import { db } from '@/db'; import { playlists } from '@/db/schema'; import { eq } from 'drizzle-orm'; // GET /api/playlists/resolve?code=ABC123 — resolve invite code to playlist export async function GET(request: NextRequest) { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'; + const limited = checkRateLimit(`public:${ip}`, RATE_LIMITS.public); + if (limited) return limited; + const code = request.nextUrl.searchParams.get('code'); if (!code) { diff --git a/src/app/api/playlists/route.ts b/src/app/api/playlists/route.ts index ab9d7cf..184f7c7 100644 --- a/src/app/api/playlists/route.ts +++ b/src/app/api/playlists/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth'; +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'; import { db } from '@/db'; import { playlists, playlistMembers, trackListens } from '@/db/schema'; import { eq, desc } from 'drizzle-orm'; -import { createPlaylist } from '@/lib/spotify'; +import { createPlaylist, uploadPlaylistImage, getPlaylistDetails } from '@/lib/spotify'; import { generateId, generateInviteCode, getFirstName, formatPlaylistName } from '@/lib/utils'; // GET /api/playlists — list user's playlists @@ -56,8 +57,12 @@ export async function GET() { // POST /api/playlists — create a new playlist export async function POST(request: NextRequest) { const user = await requireAuth(); + + const limited = checkRateLimit(`mutation:${user.id}`, RATE_LIMITS.mutation); + if (limited) return limited; + const body = await request.json(); - const { name, description } = body; + const { name, description, imageBase64 } = body; const defaultName = name || formatPlaylistName([getFirstName(user.displayName)]); const playlistId = generateId(); @@ -66,6 +71,14 @@ export async function POST(request: NextRequest) { // Create Spotify playlist const spotifyPlaylist = await createPlaylist(user.id, defaultName, description); + // Upload cover image if provided + let imageUrl: string | null = null; + if (imageBase64) { + await uploadPlaylistImage(user.id, spotifyPlaylist.id, imageBase64); + const details = await getPlaylistDetails(user.id, spotifyPlaylist.id); + imageUrl = details.imageUrl; + } + // Insert playlist await db.insert(playlists).values({ id: playlistId, @@ -74,6 +87,7 @@ export async function POST(request: NextRequest) { spotifyPlaylistId: spotifyPlaylist.id, ownerId: user.id, inviteCode, + imageUrl, }); // Owner auto-joins diff --git a/src/app/api/profile/preferences/route.ts b/src/app/api/profile/preferences/route.ts new file mode 100644 index 0000000..59bf4a6 --- /dev/null +++ b/src/app/api/profile/preferences/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { db } from '@/db'; +import { users } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { + NOTIFICATION_TYPES, + parseNotificationPrefs, + type NotificationPrefs, +} from '@/lib/notification-prefs'; + +export async function PATCH(request: NextRequest) { + const user = await requireAuth(); + const body = await request.json(); + + const updates: Record = {}; + + // Master toggles + if (body.notifyPush !== undefined) updates.notifyPush = !!body.notifyPush; + if (body.notifyEmail !== undefined) updates.notifyEmail = !!body.notifyEmail; + if (body.autoNegativeReactions !== undefined) + updates.autoNegativeReactions = !!body.autoNegativeReactions; + + // Granular notification prefs: { notificationPrefs: { newTrack: { push: true, email: false }, ... } } + if (body.notificationPrefs && typeof body.notificationPrefs === 'object') { + const currentUser = await db.query.users.findFirst({ where: eq(users.id, user.id) }); + const current = parseNotificationPrefs(currentUser?.notificationPrefs); + const incoming = body.notificationPrefs as Partial; + + for (const type of NOTIFICATION_TYPES) { + if (incoming[type]) { + if (typeof incoming[type].push === 'boolean') current[type].push = incoming[type].push; + if (typeof incoming[type].email === 'boolean') current[type].email = incoming[type].email; + } + } + + updates.notificationPrefs = JSON.stringify(current); + } + + // Reset to defaults + if (body.resetNotificationPrefs) { + updates.notificationPrefs = JSON.stringify(null); + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: 'No valid fields' }, { status: 400 }); + } + + await db.update(users).set(updates).where(eq(users.id, user.id)); + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/spotify/play/route.ts b/src/app/api/spotify/play/route.ts index e8b8e15..0f22875 100644 --- a/src/app/api/spotify/play/route.ts +++ b/src/app/api/spotify/play/route.ts @@ -6,8 +6,8 @@ export async function PUT(request: NextRequest) { const user = await requireAuth(); const { trackUri, contextUri } = await request.json(); - if (!trackUri || !contextUri) { - return NextResponse.json({ error: 'trackUri and contextUri are required' }, { status: 400 }); + if (!trackUri) { + return NextResponse.json({ error: 'trackUri is required' }, { status: 400 }); } const res = await startPlayback(user.id, { contextUri, trackUri }); @@ -31,5 +31,6 @@ export async function PUT(request: NextRequest) { } const text = await res.text().catch(() => ''); - return NextResponse.json({ error: text || 'Failed to start playback.' }, { status: res.status }); + console.error('Spotify playback failed:', res.status, text); + return NextResponse.json({ error: 'Playback failed. Please try again.' }, { status: res.status }); } diff --git a/src/app/api/spotify/search/route.ts b/src/app/api/spotify/search/route.ts index c7a0044..827b6e0 100644 --- a/src/app/api/spotify/search/route.ts +++ b/src/app/api/spotify/search/route.ts @@ -1,17 +1,22 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth'; +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'; import { searchTracks } from '@/lib/spotify'; // GET /api/spotify/search?q=... export async function GET(request: NextRequest) { const user = await requireAuth(); + + const limited = checkRateLimit(`search:${user.id}`, RATE_LIMITS.search); + if (limited) return limited; + const query = request.nextUrl.searchParams.get('q'); if (!query || query.trim().length === 0) { return NextResponse.json({ tracks: [] }); } - const tracks = await searchTracks(user.id, query.trim(), 10); + const tracks = await searchTracks(user.id, query.trim()); return NextResponse.json({ tracks: tracks.map((t) => ({ diff --git a/src/lib/email.ts b/src/lib/email.ts index b6ab29c..d324168 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -6,26 +6,43 @@ export async function sendEmail( to: string, subject: string, body: string, - url?: string + url?: string, + userId?: string ): Promise { if (!resend) { console.warn('[Swapify] Resend not configured, skipping email'); return; } + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.app'; + const unsubUrl = userId ? `${baseUrl}/api/email/unsubscribe?uid=${userId}` : null; + try { await resend.emails.send({ from: 'Swapify ', to, subject: `Swapify: ${subject}`, - html: emailTemplate(subject, body, url), + html: emailTemplate(subject, body, url, unsubUrl), + ...(unsubUrl + ? { + headers: { + 'List-Unsubscribe': `<${unsubUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + } + : {}), }); } catch (error) { console.error('[Swapify] Email send failed:', error); } } -function emailTemplate(title: string, body: string, url?: string): string { +function emailTemplate( + title: string, + body: string, + url?: string, + unsubUrl?: string | null +): string { return ` @@ -36,15 +53,15 @@ function emailTemplate(title: string, body: string, url?: string): string {
- Swapify + Swapify

${title}

${body}

- ${url ? `View in Swapify` : ''} + ${url ? `View in Swapify` : ''}

- You're receiving this because you enabled email notifications in Swapify. + You're receiving this because you enabled email notifications in Swapify.${unsubUrl ? `
Unsubscribe` : ''}

diff --git a/src/lib/notification-prefs.ts b/src/lib/notification-prefs.ts new file mode 100644 index 0000000..b7032fe --- /dev/null +++ b/src/lib/notification-prefs.ts @@ -0,0 +1,63 @@ +/** + * Granular notification preferences per event type and channel. + * Stored as JSON in users.notificationPrefs. + * When null/missing, DEFAULT_NOTIFICATION_PREFS is used. + */ + +export type NotificationType = 'newTrack' | 'memberJoined' | 'reactions' | 'trackRemoved'; + +export interface ChannelPrefs { + push: boolean; + email: boolean; +} + +export type NotificationPrefs = Record; + +export const NOTIFICATION_TYPE_LABELS: Record< + NotificationType, + { label: string; description: string } +> = { + newTrack: { label: 'New tracks', description: 'When someone adds a track' }, + memberJoined: { label: 'New members', description: 'When someone joins a Swaplist' }, + reactions: { label: 'Reactions', description: 'When someone reacts to your track' }, + trackRemoved: { label: 'Track removed', description: 'When your track is removed' }, +}; + +export const NOTIFICATION_TYPES = Object.keys(NOTIFICATION_TYPE_LABELS) as NotificationType[]; + +export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { + newTrack: { push: true, email: true }, + memberJoined: { push: true, email: true }, + reactions: { push: true, email: false }, + trackRemoved: { push: false, email: true }, +}; + +/** Parse stored JSON prefs, merging with defaults for any missing keys */ +export function parseNotificationPrefs(json: string | null | undefined): NotificationPrefs { + if (!json) return { ...DEFAULT_NOTIFICATION_PREFS }; + + try { + const parsed = JSON.parse(json) as Partial; + const result = { ...DEFAULT_NOTIFICATION_PREFS }; + for (const type of NOTIFICATION_TYPES) { + if (parsed[type]) { + result[type] = { + push: parsed[type].push ?? DEFAULT_NOTIFICATION_PREFS[type].push, + email: parsed[type].email ?? DEFAULT_NOTIFICATION_PREFS[type].email, + }; + } + } + return result; + } catch { + return { ...DEFAULT_NOTIFICATION_PREFS }; + } +} + +/** Check if a specific notification type + channel is enabled for a user */ +export function isNotificationEnabled( + prefs: NotificationPrefs, + type: NotificationType, + channel: 'push' | 'email' +): boolean { + return prefs[type]?.[channel] ?? DEFAULT_NOTIFICATION_PREFS[type][channel]; +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 677fc0b..82db3a3 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -3,6 +3,11 @@ import { db } from '@/db'; import { pushSubscriptions, users } from '@/db/schema'; import { eq } from 'drizzle-orm'; import { sendEmail } from './email'; +import { + type NotificationType, + parseNotificationPrefs, + isNotificationEnabled, +} from './notification-prefs'; // Configure web-push with VAPID keys if (process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) { @@ -19,27 +24,37 @@ interface NotificationPayload { url?: string; } -export async function notify(userId: string, payload: NotificationPayload): Promise { +export async function notify( + userId: string, + payload: NotificationPayload, + type?: NotificationType +): Promise { const user = await db.query.users.findFirst({ where: eq(users.id, userId), }); if (!user) return; - // Send push notification if enabled - if (user.notifyPush) { + const prefs = parseNotificationPrefs(user.notificationPrefs); + + // Send push notification if master toggle + per-type pref both enabled + const pushEnabled = user.notifyPush && (!type || isNotificationEnabled(prefs, type, 'push')); + if (pushEnabled) { await sendPushNotification(userId, payload); } - // Send email notification if enabled and email is set - if (user.notifyEmail && user.email) { - await sendEmail(user.email, payload.title, payload.body, payload.url); + // Send email notification if master toggle + per-type pref both enabled + email is set + const emailEnabled = + user.notifyEmail && user.email && (!type || isNotificationEnabled(prefs, type, 'email')); + if (emailEnabled) { + await sendEmail(user.email!, payload.title, payload.body, payload.url, userId); } } export async function notifyPlaylistMembers( playlistId: string, excludeUserId: string, - payload: NotificationPayload + payload: NotificationPayload, + type?: NotificationType ): Promise { const { playlistMembers } = await import('@/db/schema'); @@ -48,7 +63,7 @@ export async function notifyPlaylistMembers( }); await Promise.allSettled( - members.filter((m) => m.userId !== excludeUserId).map((m) => notify(m.userId, payload)) + members.filter((m) => m.userId !== excludeUserId).map((m) => notify(m.userId, payload, type)) ); } diff --git a/src/lib/polling.ts b/src/lib/polling.ts index 259e9eb..1c32c4c 100644 --- a/src/lib/polling.ts +++ b/src/lib/polling.ts @@ -14,11 +14,15 @@ import { removeItemsFromPlaylist, addItemsToPlaylist, getCurrentPlayback, + checkSavedTracks, isRateLimited, + TokenInvalidError, } from './spotify'; import { sendEmail } from './email'; import { generateId, getRemovalDelayMs, type RemovalDelay } from './utils'; import type { SpotifyRecentlyPlayedItem } from '@/types/spotify'; +import { logger } from '@/lib/logger'; +import { spotifyConfig, isOverBudget } from '@/lib/spotify-config'; const SKIP_THRESHOLD = 0.5; // < 50% listened = skip @@ -65,49 +69,107 @@ export async function runPollCycle(): Promise { // 3. Poll each user for (const { userId } of activeMembers) { - if (isRateLimited()) { - console.warn('[Swapify] Rate-limited by Spotify, aborting poll cycle'); + if (isRateLimited() || isOverBudget()) { + logger.warn('[Swapify] Rate-limited or over API budget, aborting poll cycle'); break; } try { await pollUser(userId, counters); } catch (error) { - console.error(`Error polling user ${userId}:`, error); + if (error instanceof TokenInvalidError) { + logger.warn(`[Swapify] Token invalid for user ${userId}, clearing refresh token`); + const user = await db.query.users.findFirst({ where: eq(users.id, userId) }); + // Clear refresh token so poller skips this user until they re-login + await db.update(users).set({ refreshToken: '' }).where(eq(users.id, userId)); + // Account-critical email — always send regardless of notifyEmail preference + if (user?.email) { + await sendEmail( + user.email, + 'Your Spotify connection needs attention', + 'Your Spotify session has expired and we can no longer track your listening. Please log in to Swapify again to reconnect your account.', + process.env.NEXT_PUBLIC_BASE_URL || 'https://swapify.app', + user.id + ); + } + continue; + } + logger.error({ error, userId }, 'Error polling user'); } await new Promise((r) => setTimeout(r, interUserDelayMs)); } - // 4. Check for age-based track expiry + // 4. Sweep for completed tracks that weren't removed on first detection + const affectedPlaylistIds = new Set(); try { - const expiredRemoved = await checkAndRemoveExpiredTracks(); - counters.tracksRemoved += expiredRemoved; + const { count, playlistIds } = await checkAndRemoveAllCompletedTracks(); + counters.tracksRemoved += count; + playlistIds.forEach((id) => affectedPlaylistIds.add(id)); } catch (error) { - console.error('[Swapify] Expired track check error:', error); + logger.error({ error }, '[Swapify] Completion sweep error'); } - // 4b. Check for delayed removal tracks (completedAt + delay elapsed) + // 5. Check for age-based track expiry try { - const delayedRemoved = await checkAndRemoveDelayedTracks(); - counters.tracksRemoved += delayedRemoved; + const { count, playlistIds } = await checkAndRemoveExpiredTracks(); + counters.tracksRemoved += count; + playlistIds.forEach((id) => affectedPlaylistIds.add(id)); } catch (error) { - console.error('[Swapify] Delayed track removal check error:', error); + logger.error({ error }, '[Swapify] Expired track check error'); } - // 5. Playlist audit every N cycles (detect external adds, enforce limits) + // 6. Check for delayed removal tracks (completedAt + delay elapsed) + try { + const { count, playlistIds } = await checkAndRemoveDelayedTracks(); + counters.tracksRemoved += count; + playlistIds.forEach((id) => affectedPlaylistIds.add(id)); + } catch (error) { + logger.error({ error }, '[Swapify] Delayed track removal check error'); + } + + // Regenerate vibe names for playlists that had tracks removed + for (const playlistId of affectedPlaylistIds) { + import('@/lib/vibe-sort').then(({ vibeSort }) => { + vibeSort(playlistId).catch(() => {}); + }); + } + + // 7. Check if members saved active tracks to their Spotify library → auto-like + savedCheckCycleCount++; + if (savedCheckCycleCount >= spotifyConfig.savedCheckEveryNCycles) { + savedCheckCycleCount = 0; + try { + await checkSavedTracksForAutoLike(); + } catch (error) { + logger.error({ error }, '[Swapify] Saved tracks auto-like check error'); + } + } + + // 8. Playlist audit every N cycles (detect external adds, enforce limits) auditCycleCount++; - if (auditCycleCount >= AUDIT_EVERY_N_CYCLES) { + if (auditCycleCount >= spotifyConfig.auditEveryNCycles) { auditCycleCount = 0; try { const auditResult = await runPlaylistAudit(); if (auditResult.unauthorizedRemoved > 0 || auditResult.overLimitRemoved > 0) { - console.log( + logger.info( `[Swapify] Audit: ${auditResult.unauthorizedRemoved} unauthorized removed, ${auditResult.overLimitRemoved} over-limit removed` ); } } catch (error) { - console.error('[Swapify] Playlist audit error:', error); + logger.error({ error }, '[Swapify] Playlist audit error'); + } + } + + // 8. Sync liked playlists every N cycles + likedSyncCycleCount++; + if (likedSyncCycleCount >= spotifyConfig.likedSyncEveryNCycles) { + likedSyncCycleCount = 0; + try { + await syncAllLikedPlaylists(); + } catch (error) { + logger.error({ error }, '[Swapify] Liked playlist sync error'); } } @@ -125,6 +187,9 @@ async function pollUser( }); if (!user) return; + // Skip users whose refresh token was cleared (needs re-login) + if (!user.refreshToken) return; + const recentTracks = await getRecentlyPlayed(userId, user.lastPollCursor ?? undefined); // Parse last playback snapshot @@ -194,7 +259,7 @@ async function pollUser( async function handleSkipDetection( userId: string, - autoNegativeReactions: number, + autoNegativeReactions: boolean, lastPlayback: PlaybackSnapshot, recentTracks: SpotifyRecentlyPlayedItem[] ): Promise { @@ -230,7 +295,7 @@ async function handleSkipDetection( userId, listenedAt: new Date(), listenDurationMs: lastPlayback.progressMs, - wasSkipped: 1, + wasSkipped: true, }); skips++; } catch { @@ -285,9 +350,6 @@ async function processRecentlyPlayed( ); if (recorded) counters.listensRecorded++; - // Auto thumbs_up for full listen (upgrades previous auto-skip reactions) - await setAutoReaction(playlistTrack.playlistId, play.track.id, userId, 'thumbs_up'); - // Check if all members have listened → remove from playlist const removed = await checkAndRemoveIfComplete(playlistTrack); if (removed) counters.tracksRemoved++; @@ -330,7 +392,7 @@ async function recordFullListen( if (existing.wasSkipped) { await db .update(trackListens) - .set({ wasSkipped: 0, listenDurationMs: durationMs, listenedAt }) + .set({ wasSkipped: false, listenDurationMs: durationMs, listenedAt }) .where(eq(trackListens.id, existing.id)); return true; } @@ -345,7 +407,7 @@ async function recordFullListen( userId, listenedAt, listenDurationMs: durationMs, - wasSkipped: 0, + wasSkipped: false, }); return true; } catch { @@ -356,10 +418,10 @@ async function recordFullListen( /** * Sets an auto-reaction for a user on a track. * - Won't overwrite manual reactions. - * - thumbs_up upgrades a previous auto thumbs_down (skip → full listen). + * - thumbs_up upgrades a previous auto thumbs_down (skip → library save). * - thumbs_down won't overwrite an existing auto thumbs_up. */ -async function setAutoReaction( +export async function setAutoReaction( playlistId: string, spotifyTrackId: string, userId: string, @@ -381,10 +443,10 @@ async function setAutoReaction( spotifyTrackId, userId, reaction, - isAuto: 1, + isAuto: true, }); } else if (existing.isAuto && reaction === 'thumbs_up' && existing.reaction === 'thumbs_down') { - // Upgrade auto thumbs_down (from skip) to thumbs_up (full listen) + // Upgrade auto thumbs_down (from skip) to thumbs_up (library save) await db .update(trackReactions) .set({ reaction: 'thumbs_up' }) @@ -407,7 +469,7 @@ async function removeAndArchiveTrack( track.spotifyTrackUri, ]); } catch (error) { - console.error('Failed to remove track from Spotify playlist:', error); + logger.error({ error }, 'Failed to remove track from Spotify playlist'); } await db @@ -415,24 +477,6 @@ async function removeAndArchiveTrack( .set({ removedAt: new Date() }) .where(eq(playlistTracks.id, track.id)); - // Archive to Keepers playlist if threshold is met - if (playlist.archiveThreshold !== 'none' && playlist.archivePlaylistId) { - try { - const shouldArchive = await evaluateArchiveThreshold(playlist, track); - if (shouldArchive) { - await addItemsToPlaylist(playlist.ownerId, playlist.archivePlaylistId, [ - track.spotifyTrackUri, - ]); - await db - .update(playlistTracks) - .set({ archivedAt: new Date() }) - .where(eq(playlistTracks.id, track.id)); - } - } catch (error) { - console.error('Failed to archive track to Keepers:', error); - } - } - return true; } @@ -455,10 +499,21 @@ async function checkAndRemoveIfComplete( ), }); - // Only count full listens (not skips) toward completion + // Reactions also count as engagement (user has heard the track) + const reactions = await db.query.trackReactions.findMany({ + where: and( + eq(trackReactions.playlistId, track.playlistId), + eq(trackReactions.spotifyTrackId, track.spotifyTrackId) + ), + }); + + // Count full listens + any reactions toward completion const fullListens = listens.filter((l) => !l.wasSkipped); - const listenedUserIds = new Set(fullListens.map((l) => l.userId)); - const allListened = members.every((m) => listenedUserIds.has(m.userId)); + const engagedUserIds = new Set([ + ...fullListens.map((l) => l.userId), + ...reactions.map((r) => r.userId), + ]); + const allListened = members.every((m) => engagedUserIds.has(m.userId)); if (!allListened) return false; @@ -484,14 +539,39 @@ async function checkAndRemoveIfComplete( return false; } +// ─── Completion Sweep (catch tracks missed by event-driven check) ────────── + +async function checkAndRemoveAllCompletedTracks(): Promise<{ + count: number; + playlistIds: Set; +}> { + const activeTracks = await db.query.playlistTracks.findMany({ + where: and(isNull(playlistTracks.removedAt), isNull(playlistTracks.completedAt)), + }); + + let count = 0; + const playlistIds = new Set(); + + for (const track of activeTracks) { + const removed = await checkAndRemoveIfComplete(track); + if (removed) { + count++; + playlistIds.add(track.playlistId); + } + } + + return { count, playlistIds }; +} + // ─── Age-Based Track Expiry ──────────────────────────────────────────────── -async function checkAndRemoveExpiredTracks(): Promise { +async function checkAndRemoveExpiredTracks(): Promise<{ count: number; playlistIds: Set }> { const expiryPlaylists = await db.query.playlists.findMany({ where: gt(playlists.maxTrackAgeDays, 0), }); - let removedCount = 0; + let count = 0; + const playlistIds = new Set(); for (const playlist of expiryPlaylists) { const cutoff = new Date(Date.now() - playlist.maxTrackAgeDays * 24 * 60 * 60 * 1000); @@ -505,33 +585,29 @@ async function checkAndRemoveExpiredTracks(): Promise { }); for (const track of oldTracks) { - // Only expire if at least 1 non-adder member has listened (any listen, including skips) - const nonAdderListen = await db.query.trackListens.findFirst({ - where: and( - eq(trackListens.playlistId, playlist.id), - eq(trackListens.spotifyTrackId, track.spotifyTrackId), - ne(trackListens.userId, track.addedByUserId) - ), - }); - - if (!nonAdderListen) continue; - + // Remove tracks that have exceeded the age limit. + // No listen requirement — the age limit is the fallback for + // listeners who aren't on the web app (their listens aren't tracked). const removed = await removeAndArchiveTrack(track, playlist); - if (removed) removedCount++; + if (removed) { + count++; + playlistIds.add(playlist.id); + } } } - return removedCount; + return { count, playlistIds }; } // ─── Delayed Track Removal ───────────────────────────────────────────────── -async function checkAndRemoveDelayedTracks(): Promise { +async function checkAndRemoveDelayedTracks(): Promise<{ count: number; playlistIds: Set }> { const pendingTracks = await db.query.playlistTracks.findMany({ where: and(isNull(playlistTracks.removedAt), isNotNull(playlistTracks.completedAt)), }); - let removedCount = 0; + let count = 0; + const playlistIds = new Set(); for (const track of pendingTracks) { const playlist = await db.query.playlists.findFirst({ @@ -544,50 +620,105 @@ async function checkAndRemoveDelayedTracks(): Promise { if (elapsed >= delayMs) { const removed = await removeAndArchiveTrack(track, playlist); - if (removed) removedCount++; + if (removed) { + count++; + playlistIds.add(track.playlistId); + } } } - return removedCount; + return { count, playlistIds }; } -async function evaluateArchiveThreshold( - playlist: typeof playlists.$inferSelect, - track: typeof playlistTracks.$inferSelect -): Promise { - const reactions = await db.query.trackReactions.findMany({ - where: and( - eq(trackReactions.playlistId, track.playlistId), - eq(trackReactions.spotifyTrackId, track.spotifyTrackId) - ), +// ─── Library Save Detection (auto-like) ───────────────────────────────── + +let savedCheckCycleCount = 0; + +/** + * For each active playlist track, check if non-adder members have saved + * it to their Spotify library. If so, auto-like it. + * Batches up to 50 track IDs per Spotify API call. + */ +async function checkSavedTracksForAutoLike(): Promise { + const activeTracks = await db.query.playlistTracks.findMany({ + where: isNull(playlistTracks.removedAt), }); - const members = await db.query.playlistMembers.findMany({ - where: and( - eq(playlistMembers.playlistId, track.playlistId), - ne(playlistMembers.userId, track.addedByUserId) - ), + if (activeTracks.length === 0) return; + + // Group tracks by playlist + const tracksByPlaylist = new Map(); + for (const track of activeTracks) { + const list = tracksByPlaylist.get(track.playlistId) ?? []; + list.push(track); + tracksByPlaylist.set(track.playlistId, list); + } + + // Get all existing thumbs_up reactions so we can skip tracks already liked + const existingUpReactions = await db.query.trackReactions.findMany({ + where: eq(trackReactions.reaction, 'thumbs_up'), }); + const upReactionKeys = new Set( + existingUpReactions.map((r) => `${r.playlistId}:${r.spotifyTrackId}:${r.userId}`) + ); - const thumbsUp = reactions.filter((r) => r.reaction === 'thumbs_up'); - const thumbsDown = reactions.filter((r) => r.reaction === 'thumbs_down'); - - switch (playlist.archiveThreshold) { - case 'no_dislikes': - return thumbsDown.length === 0; - case 'at_least_one_like': - return thumbsUp.length >= 1; - case 'universally_liked': - return members.every((m) => thumbsUp.some((r) => r.userId === m.userId)); - default: - return false; + for (const [playlistId, tracks] of tracksByPlaylist) { + if (isRateLimited() || isOverBudget()) break; + + const members = await db.query.playlistMembers.findMany({ + where: eq(playlistMembers.playlistId, playlistId), + }); + + for (const member of members) { + if (isRateLimited() || isOverBudget()) break; + await checkMemberSavedTracks(playlistId, tracks, member.userId, upReactionKeys); + } + } +} + +/** Check whether a single member has saved any of the given playlist tracks to their library. */ +async function checkMemberSavedTracks( + playlistId: string, + tracks: (typeof playlistTracks.$inferSelect)[], + userId: string, + upReactionKeys: Set +): Promise { + const uncheckedTracks = tracks.filter( + (t) => + t.addedByUserId !== userId && + !upReactionKeys.has(`${playlistId}:${t.spotifyTrackId}:${userId}`) + ); + + if (uncheckedTracks.length === 0) return; + + // Batch check in groups of 50 (Spotify API limit) + for (let i = 0; i < uncheckedTracks.length; i += 50) { + if (isRateLimited()) break; + + const batch = uncheckedTracks.slice(i, i + 50); + try { + const savedFlags = await checkSavedTracks( + userId, + batch.map((t) => t.spotifyTrackId) + ); + for (let j = 0; j < batch.length; j++) { + const track = batch[j]; + if (savedFlags[j] && track) { + await setAutoReaction(playlistId, track.spotifyTrackId, userId, 'thumbs_up'); + } + } + } catch (error) { + if (error instanceof TokenInvalidError) return; + // Rate limited or other transient error — skip batch + } } } // ─── Playlist Audit (external add enforcement) ───────────────────────── let auditCycleCount = 0; -const AUDIT_EVERY_N_CYCLES = 2; + +let likedSyncCycleCount = 0; export async function runPlaylistAudit(): Promise<{ unauthorizedRemoved: number; @@ -601,12 +732,12 @@ export async function runPlaylistAudit(): Promise<{ .where(isNull(playlistTracks.removedAt)); for (const { playlistId } of activeTrackPlaylistIds) { - if (isRateLimited()) break; + if (isRateLimited() || isOverBudget()) break; try { await auditPlaylist(playlistId, counters); } catch (error) { - console.error(`[Swapify] Audit error for playlist ${playlistId}:`, error); + logger.error({ error, playlistId }, '[Swapify] Audit error for playlist'); } } @@ -634,6 +765,7 @@ async function auditPlaylist( if (externalItems.length === 0) return; const urisToRemove: string[] = []; + let adopted = 0; for (const spotifyItem of externalItems) { const spotifyUserId = spotifyItem.added_by.id; @@ -665,14 +797,18 @@ async function auditPlaylist( urisToRemove.push(spotifyItem.track.uri); counters.overLimitRemoved++; - if (appUser.email) { - await sendEmail( - appUser.email, - 'Track removed', - `Your track "${spotifyItem.track.name}" by ${spotifyItem.track.artists.map((a) => a.name).join(', ')} was removed from "${playlist.name}" because you've reached the limit of ${playlist.maxTracksPerUser} active track${playlist.maxTracksPerUser === 1 ? '' : 's'} per member.`, - `${process.env.NEXT_PUBLIC_APP_URL}/playlist/${playlistId}` + // Notify via both push + email, respecting granular prefs + import('@/lib/notifications').then(({ notify }) => { + notify( + appUser.id, + { + title: 'Track removed', + body: `Your track "${spotifyItem.track.name}" by ${spotifyItem.track.artists.map((a) => a.name).join(', ')} was removed from "${playlist.name}" because you've reached the limit of ${playlist.maxTracksPerUser} active track${playlist.maxTracksPerUser === 1 ? '' : 's'} per member.`, + url: `${process.env.NEXT_PUBLIC_APP_URL}/playlist/${playlistId}`, + }, + 'trackRemoved' ); - } + }); continue; } } @@ -691,6 +827,7 @@ async function auditPlaylist( durationMs: spotifyItem.track.duration_ms || null, addedByUserId: appUser.id, }); + adopted++; } catch { // Unique constraint violation — already tracked } @@ -700,7 +837,103 @@ async function auditPlaylist( try { await removeItemsFromPlaylist(playlist.ownerId, playlist.spotifyPlaylistId, urisToRemove); } catch (error) { - console.error(`[Swapify] Failed to remove unauthorized/over-limit tracks:`, error); + logger.error({ error }, '[Swapify] Failed to remove unauthorized/over-limit tracks'); + } + } + + // Auto-sort playlist by vibe when new tracks were adopted (fire-and-forget) + if (adopted > 0) { + import('./vibe-sort').then(({ vibeSort }) => { + vibeSort(playlistId).catch(() => {}); + }); + } +} + +// ─── Liked Playlist Sync ───────────────────────────────────────────────── + +export async function syncLikedPlaylist( + userId: string, + playlistId: string, + likedPlaylistId: string +): Promise { + // Get user's liked reactions + const likedReactions = await db.query.trackReactions.findMany({ + where: and( + eq(trackReactions.playlistId, playlistId), + eq(trackReactions.userId, userId), + eq(trackReactions.reaction, 'thumbs_up') + ), + }); + + const likedTrackIds = new Set(likedReactions.map((r) => r.spotifyTrackId)); + + // Get track URIs for liked tracks + const allTracks = await db.query.playlistTracks.findMany({ + where: eq(playlistTracks.playlistId, playlistId), + }); + const desiredUris = new Set(); + for (const t of allTracks) { + if (likedTrackIds.has(t.spotifyTrackId)) { + desiredUris.add(t.spotifyTrackUri); + } + } + + // Get current Spotify playlist contents + let spotifyItems: Awaited>; + try { + spotifyItems = await getPlaylistItems(userId, likedPlaylistId); + } catch (error) { + if (String(error).includes('404') || String(error).includes('Not Found')) { + // Playlist was deleted — clear likedPlaylistId + const membership = await db.query.playlistMembers.findFirst({ + where: and(eq(playlistMembers.playlistId, playlistId), eq(playlistMembers.userId, userId)), + }); + if (membership) { + await db + .update(playlistMembers) + .set({ likedPlaylistId: null }) + .where(eq(playlistMembers.id, membership.id)); + } + return false; + } + throw error; + } + + const spotifyUris = new Set(spotifyItems.map((item) => item.track.uri)); + + // Diff + const toAdd = [...desiredUris].filter((uri) => !spotifyUris.has(uri)); + const toRemove = [...spotifyUris].filter((uri) => !desiredUris.has(uri)); + + if (toAdd.length > 0) { + for (let i = 0; i < toAdd.length; i += 100) { + await addItemsToPlaylist(userId, likedPlaylistId, toAdd.slice(i, i + 100)); + } + } + if (toRemove.length > 0) { + for (let i = 0; i < toRemove.length; i += 100) { + await removeItemsFromPlaylist(userId, likedPlaylistId, toRemove.slice(i, i + 100)); + } + } + + return true; +} + +async function syncAllLikedPlaylists(): Promise { + const membersWithLiked = await db.query.playlistMembers.findMany({ + where: isNotNull(playlistMembers.likedPlaylistId), + }); + + for (const member of membersWithLiked) { + if (isRateLimited() || isOverBudget()) break; + try { + await syncLikedPlaylist(member.userId, member.playlistId, member.likedPlaylistId!); + } catch (error) { + if (error instanceof TokenInvalidError) { + logger.warn(`[Swapify] Token invalid for user ${member.userId} during liked sync`); + continue; + } + logger.error({ error, userId: member.userId }, '[Swapify] Liked sync error for member'); } } } @@ -708,22 +941,30 @@ async function auditPlaylist( // ─── Polling Loop ─────────────────────────────────────────────────────────── let pollInterval: ReturnType | null = null; +let pollRunning = false; -export function startPollingLoop(intervalMs: number = 30000) { +export function startPollingLoop(intervalMs: number = spotifyConfig.pollIntervalMs) { if (pollInterval) return; - console.log(`[Swapify] Starting poll loop every ${intervalMs}ms`); + logger.info(`[Swapify] Starting poll loop every ${intervalMs}ms`); pollInterval = setInterval(async () => { + if (pollRunning) { + logger.warn('[Swapify] Previous poll cycle still running, skipping'); + return; + } + pollRunning = true; try { const result = await runPollCycle(); if (result.listensRecorded > 0 || result.skipsDetected > 0 || result.tracksRemoved > 0) { - console.log( + logger.info( `[Swapify] Poll: ${result.usersPolled} users, ${result.listensRecorded} listens, ${result.skipsDetected} skips, ${result.tracksRemoved} removed` ); } } catch (error) { - console.error('[Swapify] Poll cycle error:', error); + logger.error({ error }, '[Swapify] Poll cycle error'); + } finally { + pollRunning = false; } }, intervalMs); } @@ -732,6 +973,6 @@ export function stopPollingLoop() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; - console.log('[Swapify] Polling stopped'); + logger.info('[Swapify] Polling stopped'); } } diff --git a/src/lib/push-client.ts b/src/lib/push-client.ts new file mode 100644 index 0000000..8648f86 --- /dev/null +++ b/src/lib/push-client.ts @@ -0,0 +1,39 @@ +/** + * Shared client-side push notification subscription utilities. + * Used by NotificationPrompt and ProfileClient. + */ + +export function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +export async function subscribeToPush(): Promise { + try { + const registration = await navigator.serviceWorker.ready; + const vapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; + if (!vapidKey) return false; + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidKey) as BufferSource, + }); + + const res = await fetch('/api/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(subscription.toJSON()), + }); + + return res.ok; + } catch (err) { + console.error('[Swapify] Push subscription failed:', err); + return false; + } +} diff --git a/src/lib/spotify-config.ts b/src/lib/spotify-config.ts new file mode 100644 index 0000000..a7b786f --- /dev/null +++ b/src/lib/spotify-config.ts @@ -0,0 +1,129 @@ +/** + * Spotify API configuration that adapts to dev mode vs production. + * + * Dev mode (SPOTIFY_DEV_MODE=true): + * - Max 5 authenticated users (Spotify's dev mode limit) + * - Conservative API call budget (~50 calls/30s window) + * - Longer poll intervals, reduced background operation frequency + * - Search results capped at 5 (Feb 2026 dev mode limit) + * + * Production (SPOTIFY_DEV_MODE unset or false): + * - No user cap (extended quota mode) + * - Higher API call budget (~300 calls/30s window) + * - Normal poll intervals and background operations + * - Search results up to 10 + */ + +import { logger } from '@/lib/logger'; + +export function isSpotifyDevMode(): boolean { + return process.env.SPOTIFY_DEV_MODE === 'true'; +} + +export const spotifyConfig = { + /** Whether Spotify dev mode restrictions are active */ + get devMode() { + return isSpotifyDevMode(); + }, + + /** Max authenticated users allowed (Spotify dev mode = 5) */ + get maxUsers() { + return isSpotifyDevMode() ? 5 : Infinity; + }, + + /** Max search results per query (Feb 2026: dev mode max = 10, default = 5) */ + get searchLimit() { + return isSpotifyDevMode() ? 5 : 10; + }, + + /** Poll interval in ms (dev mode uses longer interval to conserve budget) */ + get pollIntervalMs() { + if (process.env.POLL_INTERVAL_MS) { + return Number(process.env.POLL_INTERVAL_MS); + } + return isSpotifyDevMode() ? 60_000 : 30_000; + }, + + /** + * Max Spotify API calls allowed per rolling 30-second window. + * Dev mode: very conservative (Spotify doesn't publish exact numbers, + * but community reports suggest ~100-180 for dev apps; we stay well under). + * Production: higher budget for extended quota apps. + */ + get apiCallBudget() { + return isSpotifyDevMode() ? 50 : 300; + }, + + /** How often to run saved-tracks auto-like check (in poll cycles) */ + get savedCheckEveryNCycles() { + return isSpotifyDevMode() ? 10 : 4; + }, + + /** How often to run playlist audit (in poll cycles) */ + get auditEveryNCycles() { + return isSpotifyDevMode() ? 6 : 2; + }, + + /** How often to sync liked playlists (in poll cycles) */ + get likedSyncEveryNCycles() { + return isSpotifyDevMode() ? 10 : 4; + }, +} as const; + +// ─── Global API Call Budget Tracker ───────────────────────────────────────── + +const WINDOW_MS = 30_000; +const callTimestamps: number[] = []; + +function pruneOldCalls(): void { + const cutoff = Date.now() - WINDOW_MS; + while (callTimestamps.length > 0 && callTimestamps[0]! < cutoff) { + callTimestamps.shift(); + } +} + +/** Record that a Spotify API call was just made. */ +export function trackSpotifyApiCall(): void { + callTimestamps.push(Date.now()); + pruneOldCalls(); +} + +/** Get number of Spotify API calls in the current 30-second window. */ +export function getCallsInWindow(): number { + pruneOldCalls(); + return callTimestamps.length; +} + +/** Check if we're at or over our API call budget. */ +export function isOverBudget(): boolean { + return getCallsInWindow() >= spotifyConfig.apiCallBudget; +} + +/** Check if we're approaching the budget (80%+ used). */ +export function isApproachingBudget(): boolean { + return getCallsInWindow() >= spotifyConfig.apiCallBudget * 0.8; +} + +/** + * Wait until we have budget available, or throw if we can't get it + * within a reasonable time. Used by spotifyFetch before making calls. + */ +export async function waitForBudget(maxWaitMs = 10_000): Promise { + const start = Date.now(); + while (isOverBudget()) { + if (Date.now() - start > maxWaitMs) { + logger.error( + `[Spotify] API call budget exhausted (${getCallsInWindow()}/${spotifyConfig.apiCallBudget} in 30s window)` + ); + throw new Error('Spotify API call budget exceeded — try again shortly'); + } + // Wait a bit for old calls to age out of the window + await new Promise((r) => setTimeout(r, 1_000)); + } + + if (isApproachingBudget()) { + logger.warn( + `[Spotify] Approaching API budget: ${getCallsInWindow()}/${spotifyConfig.apiCallBudget} calls in 30s window` + ); + } +} diff --git a/src/lib/spotify.ts b/src/lib/spotify.ts index f14a17a..c46d74e 100644 --- a/src/lib/spotify.ts +++ b/src/lib/spotify.ts @@ -1,6 +1,7 @@ import { db } from '@/db'; import { users } from '@/db/schema'; import { eq } from 'drizzle-orm'; +import { encrypt, decrypt } from '@/lib/crypto'; import type { SpotifyTokenResponse, SpotifyUser, @@ -11,22 +12,60 @@ import type { SpotifyPlaylistItem, } from '@/types/spotify'; +import { trackSpotifyApiCall, waitForBudget, spotifyConfig } from '@/lib/spotify-config'; + const SPOTIFY_API = 'https://api.spotify.com/v1'; const SPOTIFY_ACCOUNTS = 'https://accounts.spotify.com'; // ─── Token Management ──────────────────────────────────────────────────────── +/** Thrown when a refresh token is permanently invalid (revoked, already rotated, etc.) */ +export class TokenInvalidError extends Error { + constructor( + public userId: string, + message: string + ) { + super(message); + this.name = 'TokenInvalidError'; + } +} + +// Per-user mutex: dedup concurrent refresh attempts so only one hits Spotify +const inflightRefreshes = new Map>(); + export async function refreshAccessToken(userId: string): Promise { + // If there's already an in-flight refresh for this user, reuse it + const existing = inflightRefreshes.get(userId); + if (existing) return existing; + + const promise = _doRefresh(userId); + inflightRefreshes.set(userId, promise); + try { + return await promise; + } finally { + inflightRefreshes.delete(userId); + } +} + +async function _doRefresh(userId: string): Promise { const user = await db.query.users.findFirst({ where: eq(users.id, userId), }); if (!user) throw new Error(`User ${userId} not found`); + // User's refresh token was previously invalidated — skip + if (!user.refreshToken) { + throw new TokenInvalidError(userId, `User ${userId} has no refresh token (needs re-login)`); + } + + // Decrypt the refresh token before using it + const refreshToken = decrypt(user.refreshToken); + const now = Math.floor(Date.now() / 1000); - // If token has > 5 minutes remaining, return existing + // If token has > 5 minutes remaining, return existing (decrypt before returning) if (user.tokenExpiresAt - now > 300) { - return user.accessToken; + return decrypt(user.accessToken); } const res = await fetch(`${SPOTIFY_ACCOUNTS}/api/token`, { @@ -34,13 +73,17 @@ export async function refreshAccessToken(userId: string): Promise { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', - refresh_token: user.refreshToken, - client_id: process.env.SPOTIFY_CLIENT_ID!, + refresh_token: refreshToken, + client_id: user.spotifyClientId || process.env.SPOTIFY_CLIENT_ID!, }), }); if (!res.ok) { const err = await res.text(); + // invalid_grant = refresh token revoked or already rotated — not retryable + if (res.status === 400 && err.includes('invalid_grant')) { + throw new TokenInvalidError(userId, `Refresh token invalid for user ${userId}`); + } throw new Error(`Token refresh failed: ${res.status} ${err}`); } @@ -49,8 +92,8 @@ export async function refreshAccessToken(userId: string): Promise { await db .update(users) .set({ - accessToken: data.access_token, - refreshToken: data.refresh_token ?? user.refreshToken, + accessToken: encrypt(data.access_token), + refreshToken: data.refresh_token ? encrypt(data.refresh_token) : user.refreshToken, tokenExpiresAt: Math.floor(Date.now() / 1000) + data.expires_in, }) .where(eq(users.id, userId)); @@ -86,8 +129,12 @@ async function spotifyFetch( await new Promise((r) => setTimeout(r, waitMs)); } + // Respect global API call budget (dev mode has much lower budget) + await waitForBudget(); + const token = await refreshAccessToken(userId); + trackSpotifyApiCall(); const res = await fetch(`${SPOTIFY_API}${path}`, { ...options, headers: { @@ -108,13 +155,8 @@ async function spotifyFetch( } if (res.status === 401 && retries > 0) { - // Force refresh and retry - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - }); - if (user) { - await db.update(users).set({ tokenExpiresAt: 0 }).where(eq(users.id, userId)); - } + // Force refresh and retry — but if the token is permanently invalid, bail + await db.update(users).set({ tokenExpiresAt: 0 }).where(eq(users.id, userId)); return spotifyFetch(userId, path, options, retries - 1); } @@ -177,7 +219,13 @@ export async function uploadPlaylistImage( playlistId: string, base64Jpeg: string ): Promise { + // Image upload has its own custom rate limit on Spotify's side. + // We still track it against our global budget and respect rate limits. + await waitForBudget(); + const token = await refreshAccessToken(userId); + + trackSpotifyApiCall(); const res = await fetch(`${SPOTIFY_API}/playlists/${playlistId}/images`, { method: 'PUT', headers: { @@ -186,6 +234,13 @@ export async function uploadPlaylistImage( }, body: base64Jpeg, }); + + if (res.status === 429) { + const retryAfter = Number.parseInt(res.headers.get('Retry-After') || '5', 10); + rateLimitedUntil = Date.now() + retryAfter * 1000; + throw new Error(`Playlist image upload rate-limited, retry after ${retryAfter}s`); + } + if (!res.ok) { const err = await res.text(); throw new Error(`Failed to upload playlist image: ${res.status} ${err}`); @@ -193,9 +248,9 @@ export async function uploadPlaylistImage( } export async function followPlaylist(userId: string, playlistId: string): Promise { - const res = await spotifyFetch(userId, `/me/library`, { + const res = await spotifyFetch(userId, `/playlists/${playlistId}/followers`, { method: 'PUT', - body: JSON.stringify({ ids: [playlistId] }), + body: JSON.stringify({ public: false }), }); if (!res.ok) { const err = await res.text(); @@ -203,6 +258,24 @@ export async function followPlaylist(userId: string, playlistId: string): Promis } } +export async function getPlaylistDetails( + userId: string, + playlistId: string +): Promise<{ name: string; description: string | null; imageUrl: string | null }> { + const res = await spotifyFetch(userId, `/playlists/${playlistId}?fields=name,description,images`); + if (!res.ok) { + const err = await res.text(); + throw new Error(`Failed to get playlist details: ${res.status} ${err}`); + } + const data: { name: string; description: string | null; images: Array<{ url: string }> } = + await res.json(); + return { + name: data.name, + description: data.description || null, + imageUrl: data.images?.[0]?.url || null, + }; +} + export async function getPlaylistItems( userId: string, playlistId: string @@ -289,7 +362,7 @@ export async function getRecentlyPlayed( export async function searchTracks( userId: string, query: string, - limit = 10 + limit = spotifyConfig.searchLimit ): Promise { const params = new URLSearchParams({ q: query, @@ -322,14 +395,15 @@ export async function getCurrentPlayback( export async function startPlayback( userId: string, - options: { contextUri: string; trackUri: string } + options: { contextUri?: string; trackUri: string } ): Promise { + const body = options.contextUri + ? { context_uri: options.contextUri, offset: { uri: options.trackUri } } + : { uris: [options.trackUri] }; + return spotifyFetch(userId, '/me/player/play', { method: 'PUT', - body: JSON.stringify({ - context_uri: options.contextUri, - offset: { uri: options.trackUri }, - }), + body: JSON.stringify(body), }); } diff --git a/src/lib/vibe-name.ts b/src/lib/vibe-name.ts new file mode 100644 index 0000000..a7c232d --- /dev/null +++ b/src/lib/vibe-name.ts @@ -0,0 +1,105 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { db } from '@/db'; +import { playlists } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { logger } from '@/lib/logger'; +import type { TrackVibeScore } from '@/lib/tunebat'; + +let client: Anthropic | null = null; + +function getClient(): Anthropic | null { + if (client) return client; + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) return null; + client = new Anthropic({ apiKey }); + return client; +} + +/** + * Generate a Daylist-style vibe name for a playlist using Claude Haiku. + * + * Gracefully no-ops if ANTHROPIC_API_KEY is not set or < 4 tracks have scores. + */ +export async function generateAndSaveVibeName( + playlistId: string, + tracks: Array<{ trackName: string; artistName: string; spotifyTrackId: string }>, + vibeScores: Map +): Promise { + const anthropic = getClient(); + if (!anthropic) return; + + const scoredTracks = tracks.filter((t) => vibeScores.has(t.spotifyTrackId)); + if (scoredTracks.length < 4) return; + + try { + const vibeName = await generateVibeName(anthropic, scoredTracks, vibeScores); + if (vibeName) { + await db.update(playlists).set({ vibeName }).where(eq(playlists.id, playlistId)); + } + } catch (error) { + logger.error({ error, playlistId }, '[Swapify] Vibe name generation failed'); + } +} + +async function generateVibeName( + anthropic: Anthropic, + tracks: Array<{ trackName: string; artistName: string; spotifyTrackId: string }>, + vibeScores: Map +): Promise { + const scores = tracks + .map((t) => vibeScores.get(t.spotifyTrackId)) + .filter(Boolean) as TrackVibeScore[]; + + const avg = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length; + const avgEnergy = avg(scores.map((s) => s.energy)); + const avgDanceability = avg(scores.map((s) => s.danceability)); + const avgHappiness = avg(scores.map((s) => s.happiness)); + const avgBpm = avg(scores.map((s) => s.bpm)); + + const trackSummary = tracks + .slice(0, 15) + .map((t) => `${t.trackName} by ${t.artistName}`) + .join('\n'); + + const response = await anthropic.messages.create({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 30, + messages: [ + { + role: 'user', + content: `You are a creative music curator. Based on the playlist's audio profile and track list below, generate a short, evocative vibe label in the style of Spotify's Daylist names. + +Audio profile: +- Energy: ${avgEnergy.toFixed(2)} (0=calm, 1=intense) +- Danceability: ${avgDanceability.toFixed(2)} (0=still, 1=groovy) +- Happiness: ${avgHappiness.toFixed(2)} (0=melancholy, 1=euphoric) +- Average BPM: ${Math.round(avgBpm)} + +Tracks: +${trackSummary} + +Rules: +- 2-4 lowercase words, no punctuation +- Evocative and specific (not generic like "good vibes" or "chill music") +- Can reference mood, genre, energy, time of day, or aesthetic +- Examples: "mellow indie evening", "electric dance party", "hazy lo-fi warmth", "raw punk energy" + +Respond with ONLY the vibe label, nothing else.`, + }, + ], + }); + + const text = response.content[0]?.type === 'text' ? response.content[0].text.trim() : null; + if (!text) return null; + + const cleaned = text + .toLowerCase() + .replace(/[^a-z\s-]/g, '') + .trim(); + const wordCount = cleaned.split(/\s+/).length; + if (cleaned && wordCount >= 2 && wordCount <= 5) { + return cleaned; + } + + return null; +} diff --git a/src/lib/vibe-sort.ts b/src/lib/vibe-sort.ts index 68e1950..667c139 100644 --- a/src/lib/vibe-sort.ts +++ b/src/lib/vibe-sort.ts @@ -3,9 +3,11 @@ import { playlists, playlistTracks } from '@/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; import { reorderPlaylistTracks } from '@/lib/spotify'; import { getVibeScores } from '@/lib/tunebat'; +import { generateAndSaveVibeName } from '@/lib/vibe-name'; /** * Sort a playlist's active tracks by vibe (exciting → calm) on Spotify. + * Also regenerates the playlist's vibe name label via Claude Haiku. * Uses the playlist owner's token. Silently skips if < 2 tracks. */ export async function vibeSort(playlistId: string): Promise { @@ -20,13 +22,13 @@ export async function vibeSort(playlistId: string): Promise { if (tracks.length < 2) return; - const vibeScores = await getVibeScores( - tracks.map((t) => ({ - spotifyTrackId: t.spotifyTrackId, - trackName: t.trackName, - artistName: t.artistName, - })) - ); + const trackData = tracks.map((t) => ({ + spotifyTrackId: t.spotifyTrackId, + trackName: t.trackName, + artistName: t.artistName, + })); + + const vibeScores = await getVibeScores(trackData); const sorted = [...tracks].sort((a, b) => { const scoreA = vibeScores.get(a.spotifyTrackId)?.score ?? -1; @@ -36,4 +38,7 @@ export async function vibeSort(playlistId: string): Promise { const sortedUris = sorted.map((t) => t.spotifyTrackUri); await reorderPlaylistTracks(playlist.ownerId, playlist.spotifyPlaylistId, sortedUris); + + // Regenerate vibe name from the already-fetched scores (fire-and-forget) + generateAndSaveVibeName(playlistId, trackData, vibeScores).catch(() => {}); } From 78f123b1a7c376d655ae5ad9fe8bc534889582a6 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:11:33 -0600 Subject: [PATCH 4/9] Update CI/CD pipeline and deployment config for PostgreSQL + Fly.io - Add deploy workflow with CI gate, migration step, and Fly.io deploy - Update CI workflow to run on PRs only (deploy handles main pushes) - Remove SQLite data volume mount from fly.toml, add health check - Set min_machines_running=1 for zero-downtime - Remove SQLite data dir creation from Dockerfile - Add deploy-init.sh script for generating production secrets - Add dependabot.yml for automated dependency updates - Update .env.example with dev mode and AI config - Add color-previews and public/videos to .gitignore Co-Authored-By: Claude Opus 4.6 --- .env.example | 8 ++ .github/dependabot.yml | 25 ++++ .github/workflows/ci.yml | 6 +- .github/workflows/deploy.yml | 49 +++++++ .gitignore | 6 + Dockerfile | 3 - fly.toml | 14 +- scripts/deploy-init.sh | 268 +++++++++++++++++++++++++++++++++++ 8 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 .github/dependabot.yml create mode 100755 scripts/deploy-init.sh 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/.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/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/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/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 "" From c616be8bad91a820d902ac6687047759bd658477 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:11:45 -0600 Subject: [PATCH 5/9] Add dependencies for PostgreSQL, logging, encryption, and AI - @electric-sql/pglite: Embedded Postgres for local development - pg + @types/pg: node-postgres for production - pino: Structured JSON logging - zod: Runtime environment validation - @anthropic-ai/sdk: Vibe name generation - cal-sans: Display font - Add db:migrate and db:generate npm scripts Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 354 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 11 +- 2 files changed, 360 insertions(+), 5 deletions(-) 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", From b6167085b681979bffc0abdfd3801c63bf53484a Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:16:05 -0600 Subject: [PATCH 6/9] Overhaul UI with Arctic Aurora theme and new feature components - Implement dark-only Arctic Aurora color palette (sky blue brand, aurora green accent, deep navy gradients) - Add glassmorphism design tokens, gradient backgrounds, pill buttons - Redesign all pages: landing, dashboard, playlist detail, settings, profile, activity, join flow - Add PlaylistTabs with Active/Liked/Outcasts/History views - Add LikedTracksView, OutcastTracksView for per-user track filtering - Add NowPlayingIndicator, SpotifyChangesBanner, SpotifySetupWizard - Add custom icon components (AudioLines, Flame, HandMetal, Users) - Add useAlbumColors hook and color extraction for dynamic theming - Add loading skeletons for dashboard, playlist, activity, profile - Update BottomNav, PlaylistCard, TrackCard, ShareSheet, GlassDrawer - Enhance ProfileClient with notification preferences UI Co-Authored-By: Claude Opus 4.6 --- src/app/LandingClient.tsx | 835 ++++++++++++++++++ src/app/activity/ActivityClient.tsx | 6 +- src/app/activity/loading.tsx | 23 + src/app/dashboard/DashboardClient.tsx | 178 ++-- src/app/dashboard/loading.tsx | 27 + src/app/dashboard/page.tsx | 27 +- src/app/favicon.ico | Bin 25931 -> 2297 bytes src/app/globals.css | 98 +- src/app/layout.tsx | 26 +- src/app/login/page.tsx | 16 +- src/app/page.tsx | 90 +- .../[playlistId]/PlaylistDetailClient.tsx | 464 ++++------ src/app/playlist/[playlistId]/loading.tsx | 66 ++ src/app/playlist/[playlistId]/page.tsx | 27 +- .../playlist/[playlistId]/settings/page.tsx | 152 ++-- src/app/playlist/join/page.tsx | 52 +- src/app/profile/ProfileClient.tsx | 277 +++++- src/app/profile/loading.tsx | 45 + src/app/profile/page.tsx | 1 + src/components/BottomNav.tsx | 26 +- src/components/EditDetailsModal.tsx | 20 +- src/components/InstallPrompt.tsx | 2 +- src/components/LikedTracksView.tsx | 168 ++++ src/components/MemberBadge.tsx | 6 +- src/components/NotificationPrompt.tsx | 44 +- src/components/NowPlayingIndicator.tsx | 154 ++++ src/components/OutcastTracksView.tsx | 216 +++++ src/components/PlaylistCard.tsx | 108 ++- src/components/PlaylistTabs.tsx | 82 ++ src/components/ProgressIndicator.tsx | 4 +- src/components/ReactionOverlay.tsx | 29 +- src/components/ShareSheet.tsx | 90 +- src/components/SpotifyChangesBanner.tsx | 210 +++++ src/components/SpotifySetupWizard.tsx | 366 ++++++++ src/components/SwipeableTrackCard.tsx | 13 +- src/components/TrackCard.tsx | 65 +- src/components/TrackSearch.tsx | 4 +- src/components/ui/audio-lines.tsx | 145 +++ src/components/ui/flame.tsx | 114 +++ src/components/ui/glass-drawer.tsx | 18 +- src/components/ui/hand-metal.tsx | 115 +++ src/components/ui/sonner.tsx | 2 +- src/components/ui/users.tsx | 114 +++ src/hooks/useAlbumColors.ts | 67 ++ src/lib/color-extract.ts | 152 ++++ 45 files changed, 3969 insertions(+), 775 deletions(-) create mode 100644 src/app/LandingClient.tsx create mode 100644 src/app/activity/loading.tsx create mode 100644 src/app/dashboard/loading.tsx create mode 100644 src/app/playlist/[playlistId]/loading.tsx create mode 100644 src/app/profile/loading.tsx create mode 100644 src/components/LikedTracksView.tsx create mode 100644 src/components/NowPlayingIndicator.tsx create mode 100644 src/components/OutcastTracksView.tsx create mode 100644 src/components/PlaylistTabs.tsx create mode 100644 src/components/SpotifyChangesBanner.tsx create mode 100644 src/components/SpotifySetupWizard.tsx create mode 100644 src/components/ui/audio-lines.tsx create mode 100644 src/components/ui/flame.tsx create mode 100644 src/components/ui/hand-metal.tsx create mode 100644 src/components/ui/users.tsx create mode 100644 src/hooks/useAlbumColors.ts create mode 100644 src/lib/color-extract.ts 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