diff --git a/apps/api/drizzle/0010_avatars.sql b/apps/api/drizzle/0010_avatars.sql new file mode 100644 index 0000000..40f167e --- /dev/null +++ b/apps/api/drizzle/0010_avatars.sql @@ -0,0 +1,9 @@ +CREATE TABLE "avatars" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "image" "bytea" NOT NULL, + "content_type" text NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "avatars" ADD CONSTRAINT "avatars_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +UPDATE "users" SET "display_name" = "username" WHERE "display_name" IS NULL OR "display_name" = ''; diff --git a/apps/api/drizzle/meta/0010_snapshot.json b/apps/api/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..005f579 --- /dev/null +++ b/apps/api/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1251 @@ +{ + "id": "89f8acc3-35bc-4587-8fb0-4e98abbb730b", + "prevId": "0ec3157f-efbd-4469-a98c-24e2a0e49549", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.avatars": { + "name": "avatars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "avatars_user_id_users_id_fk": { + "name": "avatars_user_id_users_id_fk", + "tableFrom": "avatars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_id": { + "name": "blocked_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocks_blocked_idx": { + "name": "blocks_blocked_idx", + "columns": [ + { + "expression": "blocked_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocks_blocker_id_users_id_fk": { + "name": "blocks_blocker_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocks_blocked_id_users_id_fk": { + "name": "blocks_blocked_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocked_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocks_blocker_id_blocked_id_pk": { + "name": "blocks_blocker_id_blocked_id_pk", + "columns": [ + "blocker_id", + "blocked_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connections_user_provider_idx": { + "name": "connections_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_user_id_users_id_fk": { + "name": "connections_user_id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "connections_webhook_secret_unique": { + "name": "connections_webhook_secret_unique", + "nullsNotDistinct": false, + "columns": [ + "webhook_secret" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_idx": { + "name": "devices_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_token_hash_unique": { + "name": "devices_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.featured_songs": { + "name": "featured_songs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "varchar(280)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "featured_songs_user_id_users_id_fk": { + "name": "featured_songs_user_id_users_id_fk", + "tableFrom": "featured_songs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "featured_songs_track_id_tracks_id_fk": { + "name": "featured_songs_track_id_tracks_id_fk", + "tableFrom": "featured_songs", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "followee_id": { + "name": "followee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "follows_followee_idx": { + "name": "follows_followee_idx", + "columns": [ + { + "expression": "followee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_follower_id_users_id_fk": { + "name": "follows_follower_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_followee_id_users_id_fk": { + "name": "follows_followee_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "followee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_follower_id_followee_id_pk": { + "name": "follows_follower_id_followee_id_pk", + "columns": [ + "follower_id", + "followee_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listens": { + "name": "listens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listens_user_started_idx": { + "name": "listens_user_started_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listens_user_id_users_id_fk": { + "name": "listens_user_id_users_id_fk", + "tableFrom": "listens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listens_track_id_tracks_id_fk": { + "name": "listens_track_id_tracks_id_fk", + "tableFrom": "listens", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.now_playing": { + "name": "now_playing", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_playing": { + "name": "is_playing", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "now_playing_track_idx": { + "name": "now_playing_track_idx", + "columns": [ + { + "expression": "track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "now_playing_user_id_users_id_fk": { + "name": "now_playing_user_id_users_id_fk", + "tableFrom": "now_playing", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "now_playing_track_id_tracks_id_fk": { + "name": "now_playing_track_id_tracks_id_fk", + "tableFrom": "now_playing", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reported_id": { + "name": "reported_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_reporter_id_users_id_fk": { + "name": "reports_reporter_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reports_reported_id_users_id_fk": { + "name": "reports_reported_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reported_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.track_links": { + "name": "track_links", + "schema": "", + "columns": { + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "track_links_external_idx": { + "name": "track_links_external_idx", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "track_links_track_id_tracks_id_fk": { + "name": "track_links_track_id_tracks_id_fk", + "tableFrom": "track_links", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "track_links_track_id_service_pk": { + "name": "track_links_track_id_service_pk", + "columns": [ + "track_id", + "service" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracks": { + "name": "tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "isrc": { + "name": "isrc", + "type": "varchar(12)", + "primaryKey": false, + "notNull": false + }, + "match_key": { + "name": "match_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist": { + "name": "artist", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "album": { + "name": "album", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "artwork_url": { + "name": "artwork_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "links_resolved_at": { + "name": "links_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracks_isrc_idx": { + "name": "tracks_isrc_idx", + "columns": [ + { + "expression": "isrc", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_match_key_idx": { + "name": "tracks_match_key_idx", + "columns": [ + { + "expression": "match_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "varchar(280)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "preferred_service": { + "name": "preferred_service", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sharing_paused_until": { + "name": "sharing_paused_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "plan_expires_at": { + "name": "plan_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index fe3697d..a9ca649 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1791240840729, "tag": "0009_black_wild_pack", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1791257078479, + "tag": "0010_avatars", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index e85c217..de5f76e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,6 +21,7 @@ "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", + "@fastify/multipart": "^10.1.2", "@fastify/rate-limit": "^11.2.0", "@fastify/websocket": "^11.3.2", "@musicshare/shared": "*", @@ -31,6 +32,7 @@ "fastify": "^5.12.5", "ioredis": "^6.0.0", "postgres": "^3.4.9", + "sharp": "^0.35.5", "undici": "^6.29.0", "zod": "^4.6.5" }, diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 3cbd094..6314f18 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,6 +1,7 @@ import cookie from '@fastify/cookie'; import cors from '@fastify/cors'; import rateLimit from '@fastify/rate-limit'; +import multipart from '@fastify/multipart'; import websocket from '@fastify/websocket'; import Fastify from 'fastify'; import { ZodError } from 'zod'; @@ -11,6 +12,7 @@ import { HttpError } from './lib/http'; import { redis } from './lib/redis'; import { adminRoutes } from './routes/admin'; import { authRoutes } from './routes/auth'; +import { avatarRoutes } from './routes/avatars'; import { connectionRoutes } from './routes/connections'; import { deviceRoutes } from './routes/devices'; import { meRoutes } from './routes/me'; @@ -41,6 +43,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?: // Rate limits are per-route (see `config.rateLimit`); the Redis store makes them global across instances. if (rateLimits) await app.register(rateLimit, { global: false, redis }); await app.register(websocket); + await app.register(multipart); app.decorateRequest('user', null); app.decorateRequest('sessionId', null); @@ -64,6 +67,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?: await app.register(authRoutes); await app.register(meRoutes); + await app.register(avatarRoutes); await app.register(userRoutes); await app.register(safetyRoutes); await app.register(adminRoutes); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 4d2705a..ada9922 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,6 +1,7 @@ import { sql } from 'drizzle-orm'; import { boolean, + customType, index, integer, jsonb, @@ -23,6 +24,7 @@ export const users = pgTable('users', { passwordHash: text('password_hash').notNull(), displayName: varchar('display_name', { length: 64 }), bio: varchar('bio', { length: 280 }), + /** Path of the uploaded picture (`/avatars/?v=`), resolved against API_PUBLIC_URL when sent. */ avatarUrl: varchar('avatar_url', { length: 500 }), preferredService: text('preferred_service').$type(), /** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */ @@ -236,6 +238,18 @@ export const featuredSongs = pgTable('featured_songs', { .default(sql`now()`), }); +const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' }); + +/** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */ +export const avatars = pgTable('avatars', { + userId: uuid('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + image: bytea('image').notNull(), + contentType: text('content_type').notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + export type User = typeof users.$inferSelect; export type Connection = typeof connections.$inferSelect; export type Track = typeof tracks.$inferSelect; diff --git a/apps/api/src/lib/avatars.ts b/apps/api/src/lib/avatars.ts new file mode 100644 index 0000000..95c4095 --- /dev/null +++ b/apps/api/src/lib/avatars.ts @@ -0,0 +1,29 @@ +import sharp from 'sharp'; +import { env } from '../env'; +import { HttpError } from './http'; + +export const AVATAR_SIZE = 512; +export const AVATAR_MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + +/** + * Uploaded avatars are stored as a path (`/avatars/?v=`) so they keep + * working if the API moves; older rows may hold a full external URL. + */ +export const publicAvatarUrl = (stored: string | null): string | null => + stored?.startsWith('/') ? env.API_PUBLIC_URL.replace(/\/$/, '') + stored : stored; + +/** + * Square-crops, resizes and re-encodes to WebP. Re-encoding also drops EXIF + * (phone photos carry GPS location) and anything that isn't image data. + */ +export async function processAvatar(input: Buffer): Promise { + try { + return await sharp(input, { limitInputPixels: 50_000_000, animated: false }) + .rotate() // apply the EXIF orientation before it's stripped + .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' }) + .webp({ quality: 82 }) + .toBuffer(); + } catch { + throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC."); + } +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 7582a0a..1e4917d 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -12,6 +12,9 @@ const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } export async function authRoutes(app: FastifyInstance) { app.post('/auth/register', authRateLimit, async (req, reply) => { const input = registerSchema.parse(req.body); + // Usernames are stored lowercase; keep the capitalization they typed as the display name. + const typed = String((req.body as { username?: unknown }).username ?? '').trim(); + const displayName = typed.toLowerCase() === input.username ? typed : input.username; const [taken] = await db .select({ username: schema.users.username, email: schema.users.email }) .from(schema.users) @@ -25,7 +28,7 @@ export async function authRoutes(app: FastifyInstance) { const [{ n }] = (await tx.select({ n: count() }).from(schema.users)) as [{ n: number }]; const [created] = await tx .insert(schema.users) - .values({ username: input.username, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' }) + .values({ username: input.username, displayName, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' }) .returning(); return created; }); diff --git a/apps/api/src/routes/avatars.ts b/apps/api/src/routes/avatars.ts new file mode 100644 index 0000000..828a5a8 --- /dev/null +++ b/apps/api/src/routes/avatars.ts @@ -0,0 +1,56 @@ +import { eq } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { db, schema } from '../db'; +import { requireUser } from '../lib/auth'; +import { AVATAR_MAX_UPLOAD_BYTES, processAvatar } from '../lib/avatars'; +import { HttpError } from '../lib/http'; +import { toMeDTO } from './me'; + +export async function avatarRoutes(app: FastifyInstance) { + /** Multipart upload with one file field. Returns the updated profile. */ + app.post('/me/avatar', { config: { rateLimit: { max: 20, timeWindow: '1 hour' } } }, async (req) => { + const user = requireUser(req); + const file = await req.file({ limits: { fileSize: AVATAR_MAX_UPLOAD_BYTES, files: 1 } }).catch(() => undefined); + if (!file) throw new HttpError(400, 'Choose a picture to upload'); + const input = await file.toBuffer().catch(() => { + throw new HttpError(413, 'That picture is too big (10 MB max)'); + }); + const image = await processAvatar(input); + + const values = { image, contentType: 'image/webp', updatedAt: new Date() }; + await db + .insert(schema.avatars) + .values({ userId: user.id, ...values }) + .onConflictDoUpdate({ target: schema.avatars.userId, set: values }); + // The version busts caches, since the image itself is served as immutable. + const [updated] = await db + .update(schema.users) + .set({ avatarUrl: `/avatars/${user.id}?v=${values.updatedAt.getTime()}` }) + .where(eq(schema.users.id, user.id)) + .returning(); + return toMeDTO(updated!); + }); + + app.delete('/me/avatar', async (req) => { + const user = requireUser(req); + await db.delete(schema.avatars).where(eq(schema.avatars.userId, user.id)); + const [updated] = await db.update(schema.users).set({ avatarUrl: null }).where(eq(schema.users.id, user.id)).returning(); + return toMeDTO(updated!); + }); + + app.get('/avatars/:userId', async (req, reply) => { + const { userId } = z.object({ userId: z.uuid() }).parse(req.params); + const [row] = await db + .select({ image: schema.avatars.image, contentType: schema.avatars.contentType }) + .from(schema.avatars) + .where(eq(schema.avatars.userId, userId)); + if (!row) throw new HttpError(404, 'No picture'); + return reply + .header('Content-Type', row.contentType) + .header('Cache-Control', 'public, max-age=31536000, immutable') + .header('Content-Security-Policy', "default-src 'none'") + .header('Cross-Origin-Resource-Policy', 'cross-origin') + .send(row.image); + }); +} diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 469353c..b09aa5d 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -4,6 +4,7 @@ import { setFeaturedSchema, setSharingSchema, updateMeSchema, type MeDTO } from import { db, schema } from '../db'; import type { User } from '../db/schema'; import { SESSION_COOKIE, requireUser } from '../lib/auth'; +import { publicAvatarUrl } from '../lib/avatars'; import { effectivePlan, entitlementsFor } from '../lib/entitlements'; import { HttpError } from '../lib/http'; import { deezerTrack } from '../providers/catalog'; @@ -17,7 +18,7 @@ export const toMeDTO = (u: User): MeDTO => ({ id: u.id, username: u.username, displayName: u.displayName, - avatarUrl: u.avatarUrl, + avatarUrl: publicAvatarUrl(u.avatarUrl), email: u.email, bio: u.bio, preferredService: u.preferredService, @@ -34,6 +35,8 @@ export async function meRoutes(app: FastifyInstance) { app.patch('/me', async (req) => { const user = requireUser(req); const { onboarded, ...input } = updateMeSchema.parse(req.body); + // Clearing the display name falls back to the username. + if ('displayName' in input && !input.displayName) input.displayName = user.username; const changes = { ...input, ...(onboarded && !user.onboardedAt ? { onboardedAt: new Date() } : {}) }; if (Object.keys(changes).length === 0) return toMeDTO(user); // nothing to update const [updated] = await db.update(schema.users).set(changes).where(eq(schema.users.id, user.id)).returning(); diff --git a/apps/api/src/services/nowPlaying.ts b/apps/api/src/services/nowPlaying.ts index 17bcb6c..5732231 100644 --- a/apps/api/src/services/nowPlaying.ts +++ b/apps/api/src/services/nowPlaying.ts @@ -2,6 +2,7 @@ import { and, desc, eq, inArray, lt } from 'drizzle-orm'; import type { NowPlayingDTO, NowPlayingProvider, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared'; import { db, schema } from '../db'; import type { User } from '../db/schema'; +import { publicAvatarUrl } from '../lib/avatars'; import { nowPlayingChannel, redis } from '../lib/redis'; import type { PollResult } from '../providers/types'; import { enqueueResolve } from './queues'; @@ -11,7 +12,7 @@ export const toUserSummary = (u: Pick + sharp({ create: { width: 1200, height: 800, channels: 3, background: '#7b2ff7' } }) + .jpeg() + .withExif({ IFD0: { Make: 'TestPhone', Model: 'GPS-Leaker' } }) + .toBuffer(); + +describe('avatars', () => { + it('resizes, re-encodes and serves an uploaded picture', async () => { + const { token, user } = await createUser(app(), 'alice'); + const { payload, headers } = multipart(await photo()); + const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } }); + expect(res.statusCode).toBe(200); + const url: string = res.json().avatarUrl; + expect(url).toMatch(new RegExp(`^https://api\\.test/avatars/${user.id}\\?v=\\d+$`)); + + const img = await app().inject({ url: url.replace('https://api.test', '') }); + expect(img.statusCode).toBe(200); + expect(img.headers['content-type']).toBe('image/webp'); + expect(img.headers['cache-control']).toContain('immutable'); + const meta = await sharp(img.rawPayload).metadata(); + expect(meta).toMatchObject({ format: 'webp', width: 512, height: 512 }); + expect(meta.exif).toBeUndefined(); // camera/GPS metadata stripped + + // Shows up wherever the user does, e.g. their profile. + const profile = await app().inject({ url: '/users/alice', headers: bearer(token) }); + expect(profile.json().user.avatarUrl).toBe(url); + }); + + it('rejects files that are not images', async () => { + const { token } = await createUser(app(), 'alice'); + const { payload, headers } = multipart(Buffer.from(''), 'image/png', 'x.png'); + const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/isn't an image/); + }); + + it('removes the picture', async () => { + const { token, user } = await createUser(app(), 'alice'); + const { payload, headers } = multipart(await photo()); + await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } }); + const res = await app().inject({ method: 'DELETE', url: '/me/avatar', headers: bearer(token) }); + expect(res.json().avatarUrl).toBeNull(); + expect((await app().inject({ url: `/avatars/${user.id}` })).statusCode).toBe(404); + }); + + it('requires sign-in to upload', async () => { + const { payload, headers } = multipart(await photo()); + expect((await app().inject({ method: 'POST', url: '/me/avatar', payload, headers })).statusCode).toBe(401); + }); +}); + +describe('display name', () => { + it('defaults to the username as typed, and falls back to it when cleared', async () => { + const res = await app().inject({ + method: 'POST', + url: '/auth/register', + payload: { username: 'BinaryGnome', email: 'g@example.com', password: 'password123' }, + }); + expect(res.json().user).toMatchObject({ username: 'binarygnome', displayName: 'BinaryGnome' }); + + const cleared = await app().inject({ method: 'PATCH', url: '/me', headers: bearer(res.json().token), payload: { displayName: '' } }); + expect(cleared.json().displayName).toBe('binarygnome'); + }); +}); diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts index cda90f8..a998e4d 100644 --- a/apps/api/test/helpers.ts +++ b/apps/api/test/helpers.ts @@ -17,7 +17,7 @@ function assertTestTargets() { export async function resetState() { assertTestTargets(); - await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports restart identity cascade`; + await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars restart identity cascade`; await redis.flushdb(); } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index d16207d..0816c53 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -42,6 +42,14 @@ "microphonePermission": false, "recordAudioAndroid": false } + ], + [ + "expo-image-picker", + { + "photosPermission": "musicshare lets you choose a profile picture from your photos.", + "cameraPermission": "musicshare uses the camera to scan friends' QR codes.", + "microphonePermission": false + } ] ], "experiments": { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index be490fd..e358b5d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -15,6 +15,7 @@ "expo-glass-effect": "~57.0.4", "expo-haptics": "~57.0.3", "expo-image": "~57.0.5", + "expo-image-picker": "~57.0.20", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.11", "expo-router": "~57.0.24", diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index d95b4f6..265c50f 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -1,17 +1,21 @@ +import * as ImagePicker from 'expo-image-picker'; import { router } from 'expo-router'; import { useState } from 'react'; -import { Alert, Platform, StyleSheet, Text, View } from 'react-native'; +import { Alert, Platform, StyleSheet, View } from 'react-native'; import useSWR from 'swr'; import { SERVICE_LABELS, STREAMING_SERVICES, timeAgo, type ConnectionDTO, + type MeDTO, type NowPlayingProvider, } from '@musicshare/shared'; +import { Avatar } from '@/components/Avatar'; import { RelinkReminder } from '@/components/RelinkReminder'; import { FeaturedPicker } from '@/components/FeaturedPicker'; import { Screen } from '@/components/Screen'; +import { ServiceName } from '@/components/ServiceName'; import { SharingControls } from '@/components/SharingControls'; import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui'; import { api } from '@/lib/api'; @@ -57,6 +61,7 @@ function ProfileSection() { return ( Profile + + {me.avatarUrl && ( + + )} + + {error &&

{error}

} + + + + ); +} + function Connections() { const params = useSearchParams(); const { data: connections = [], mutate } = useSWR('/connections', fetcher); @@ -154,7 +203,7 @@ function Connections() { {/* Spotify caps new apps at a handful of accounts; Spotify's own Last.fm/ListenBrainz link works for everyone. */}
- Spotify +
Spotify sends everything you play to Last.fm or ListenBrainz, and we share it from there. Connect one of them below, then link Spotify on their site: @@ -181,7 +230,7 @@ function Connections() {
- Plex +
{status('plex') ?? Music you play from your Plex servers}
@@ -198,7 +247,7 @@ function Connections() {
- Jellyfin +
{status('jellyfin') ?? Sign in to your Jellyfin server. Your password isn't stored.}
{byProvider.get('jellyfin')?.status !== 'active' && ( @@ -230,7 +279,7 @@ function Connections() { {(['lastfm', 'listenbrainz'] as const).map((p) => (
- {SERVICE_LABELS[p]} +
{status(p) ?? ( @@ -267,7 +316,7 @@ function Connections() {
- Apple Music +
{byProvider.has('apple_music') ? status('apple_music') : 'Connect from the musicshare iPhone app (one tap)'}
diff --git a/apps/web/src/components/ServiceName.tsx b/apps/web/src/components/ServiceName.tsx new file mode 100644 index 0000000..b8a61f1 --- /dev/null +++ b/apps/web/src/components/ServiceName.tsx @@ -0,0 +1,14 @@ +import { SERVICE_COLORS, SERVICE_LABELS, type NowPlayingProvider, type StreamingService } from '@musicshare/shared'; + +/** A service's name with a badge in its brand colour, so it's easy to spot in a list. */ +export function ServiceName({ service }: { service: StreamingService | NowPlayingProvider }) { + const label = SERVICE_LABELS[service]; + return ( + + + {label.slice(0, 1).toUpperCase()} + + {label} + + ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 91e4c80..074c9d1 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -26,6 +26,16 @@ export async function api(path: string, init: { method?: string; bo return data as T; } +/** Uploads one file as multipart form data (e.g. the profile picture). */ +export async function uploadFile(path: string, file: File): Promise { + const form = new FormData(); + form.append('file', file); + const res = await fetch(API_URL + path, { method: 'POST', credentials: 'include', body: form }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? res.statusText); + return data as T; +} + export const fetcher = (path: string) => api(path); export function useMe() { diff --git a/package-lock.json b/package-lock.json index 8c5f695..5ec1514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", + "@fastify/multipart": "^10.1.2", "@fastify/rate-limit": "^11.2.0", "@fastify/websocket": "^11.3.2", "@musicshare/shared": "*", @@ -33,6 +34,7 @@ "fastify": "^5.12.5", "ioredis": "^6.0.0", "postgres": "^3.4.9", + "sharp": "^0.35.5", "undici": "^6.29.0", "zod": "^4.6.5" }, @@ -815,6 +817,7 @@ "expo-glass-effect": "~57.0.4", "expo-haptics": "~57.0.3", "expo-image": "~57.0.5", + "expo-image-picker": "~57.0.20", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.11", "expo-router": "~57.0.24", @@ -4313,6 +4316,12 @@ "fast-uri": "^4.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.2.tgz", + "integrity": "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==", + "license": "MIT" + }, "node_modules/@fastify/cookie": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", @@ -4353,6 +4362,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.2.tgz", + "integrity": "sha512-155eqA/WMQdN6gUD4RrhS2K0Pcz2Ou9pQ86L0sipOpP9ONbTA6oYw/hW4VdBjR4egcDHqmWUK9x80oUX7S1mDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -4423,6 +4448,29 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.1.2.tgz", + "integrity": "sha512-WHsqW5ffDXCbXXtdBQDryxu58fQvFPVGh4E4MlCO6hwCLNm0lYTCS6GKXUw2P64REDoECUNpMVvtMMstdIyBjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^6.0.0", + "secure-json-parse": "^4.0.0" + } + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", @@ -4557,7 +4605,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -11169,6 +11216,27 @@ } } }, + "node_modules/expo-image-loader": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz", + "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz", + "integrity": "sha512-plgcGuMZMWqDchjXQW6S/WepYLB5KqfbZOzwC6xe0+j5IhY4ipvVuRZcXeP/0NgKgU0Jiu/XoTK4Bi8crtZ+sQ==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", @@ -17416,7 +17484,6 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index 077a0bd..ebc08bb 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -46,6 +46,17 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt return data as T; } + /** Multipart upload (the browser/React Native sets the boundary itself). */ + async function upload(path: string, form: FormData): Promise { + const headers: Record = {}; + const token = getToken?.(); + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(baseUrl + path, { method: 'POST', headers, credentials, body: form }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? `Upload failed (${res.status})`); + return data as T; + } + const q = encodeURIComponent; return { @@ -60,6 +71,9 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt me: () => request('/me'), updateMe: (input: UpdateMeInput) => request('/me', { method: 'PATCH', body: input }), deleteAccount: () => request('/me', { method: 'DELETE' }), + /** `form` holds one file field (any name): a File on web, `{ uri, name, type }` on React Native. */ + uploadAvatar: (form: FormData) => upload('/me/avatar', form), + removeAvatar: () => request('/me/avatar', { method: 'DELETE' }), setSharing: (pause: '1h' | '8h' | 'indefinitely' | null) => request('/me/sharing', { method: 'PUT', body: { pause } }), setFeatured: (input: SetFeaturedInput) => request('/me/featured', { method: 'PUT', body: input }), clearFeatured: () => request('/me/featured', { method: 'DELETE' }), diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index f23cbd9..bf8b29c 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -24,7 +24,6 @@ export const loginSchema = z.object({ export const updateMeSchema = z.object({ displayName: z.string().trim().max(64).nullable().optional(), bio: z.string().trim().max(280).nullable().optional(), - avatarUrl: z.url().max(500).nullable().optional(), preferredService: z.enum(STREAMING_SERVICES).nullable().optional(), /** Mark onboarding as finished. */ onboarded: z.literal(true).optional(), diff --git a/packages/shared/src/services.ts b/packages/shared/src/services.ts index 7b28a74..cb4356e 100644 --- a/packages/shared/src/services.ts +++ b/packages/shared/src/services.ts @@ -33,6 +33,20 @@ export const SERVICE_LABELS: Record = { + spotify: '#1DB954', + apple_music: '#FA2D48', + youtube_music: '#FF0033', + deezer: '#A238FF', + tidal: '#000000', + lastfm: '#D51007', + listenbrainz: '#EB743B', + plex: '#E5A00D', + jellyfin: '#AA5CC3', + android_media_session: '#5b4cf0', +}; + /** Fallback when we have no direct link: a search on that service. */ export function searchUrl(service: StreamingService, title: string, artist: string): string { const q = encodeURIComponent(`${artist} ${title}`);