diff --git a/apps/api/.env.example b/apps/api/.env.example index be20176..0334073 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -28,3 +28,10 @@ ALLOW_PRIVATE_SERVER_URLS=false APPLE_TEAM_ID= APPLE_MUSICKIT_KEY_ID= APPLE_MUSICKIT_PRIVATE_KEY= + +# Outgoing email (verification, password reset). SMTPS on 465. +SMTP_HOST= +SMTP_PORT=465 +SMTP_USER= +SMTP_PASSWORD= +MAIL_FROM=musicshare diff --git a/apps/api/drizzle/0007_lowly_wilson_fisk.sql b/apps/api/drizzle/0007_lowly_wilson_fisk.sql new file mode 100644 index 0000000..113765a --- /dev/null +++ b/apps/api/drizzle/0007_lowly_wilson_fisk.sql @@ -0,0 +1,3 @@ +ALTER TABLE "users" ADD COLUMN "onboarded_at" timestamp with time zone;--> statement-breakpoint +-- Accounts that existed before onboarding shipped have already set up; do not show it to them. +UPDATE "users" SET "onboarded_at" = "created_at" WHERE "onboarded_at" IS NULL; diff --git a/apps/api/drizzle/0008_romantic_nomad.sql b/apps/api/drizzle/0008_romantic_nomad.sql new file mode 100644 index 0000000..4ef0777 --- /dev/null +++ b/apps/api/drizzle/0008_romantic_nomad.sql @@ -0,0 +1,23 @@ +CREATE TABLE "blocks" ( + "blocker_id" uuid NOT NULL, + "blocked_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "blocks_blocker_id_blocked_id_pk" PRIMARY KEY("blocker_id","blocked_id") +); +--> statement-breakpoint +CREATE TABLE "reports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "reporter_id" uuid, + "reported_id" uuid NOT NULL, + "reason" text NOT NULL, + "note" varchar(500), + "status" text DEFAULT 'open' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "blocks" ADD CONSTRAINT "blocks_blocker_id_users_id_fk" FOREIGN KEY ("blocker_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blocks" ADD CONSTRAINT "blocks_blocked_id_users_id_fk" FOREIGN KEY ("blocked_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reports" ADD CONSTRAINT "reports_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reports" ADD CONSTRAINT "reports_reported_id_users_id_fk" FOREIGN KEY ("reported_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "blocks_blocked_idx" ON "blocks" USING btree ("blocked_id");--> statement-breakpoint +CREATE INDEX "reports_status_idx" ON "reports" USING btree ("status"); \ No newline at end of file diff --git a/apps/api/drizzle/0009_black_wild_pack.sql b/apps/api/drizzle/0009_black_wild_pack.sql new file mode 100644 index 0000000..cf9f58e --- /dev/null +++ b/apps/api/drizzle/0009_black_wild_pack.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" ADD COLUMN "role" text DEFAULT 'user' NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "suspended_at" timestamp with time zone; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0007_snapshot.json b/apps/api/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..89152e6 --- /dev/null +++ b/apps/api/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1002 @@ +{ + "id": "71acfb53-09f0-443b-8fcb-9cf53d1f9c43", + "prevId": "e78b7958-04e3-4e67-bf2e-1d40028ac400", + "version": "7", + "dialect": "postgresql", + "tables": { + "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.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 + }, + "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/0008_snapshot.json b/apps/api/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..d47562c --- /dev/null +++ b/apps/api/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1186 @@ +{ + "id": "cfcb8e53-36ac-4172-9c3d-2e701a6295b4", + "prevId": "71acfb53-09f0-443b-8fcb-9cf53d1f9c43", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + }, + "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/0009_snapshot.json b/apps/api/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..6a12680 --- /dev/null +++ b/apps/api/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1199 @@ +{ + "id": "0ec3157f-efbd-4469-a98c-24e2a0e49549", + "prevId": "cfcb8e53-36ac-4172-9c3d-2e701a6295b4", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 252d42f..fe3697d 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -50,6 +50,27 @@ "when": 1791114009783, "tag": "0006_grey_korvac", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1791116118996, + "tag": "0007_lowly_wilson_fisk", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1791239885734, + "tag": "0008_romantic_nomad", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1791240840729, + "tag": "0009_black_wild_pack", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 1365e45..e85c217 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,7 +14,9 @@ "test": "vitest run", "test:unit": "vitest run --project unit", "test:watch": "vitest", - "start:migrate": "node dist/db/migrate.js" + "start:migrate": "node dist/db/migrate.js", + "admin:promote": "tsx src/scripts/set-role.ts admin", + "admin:demote": "tsx src/scripts/set-role.ts user" }, "dependencies": { "@fastify/cookie": "^11.1.2", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 77e4ad6..8572652 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,11 +9,13 @@ import { env } from './env'; import { loadUser } from './lib/auth'; import { HttpError } from './lib/http'; import { redis } from './lib/redis'; +import { adminRoutes } from './routes/admin'; import { authRoutes } from './routes/auth'; import { connectionRoutes } from './routes/connections'; import { deviceRoutes } from './routes/devices'; import { meRoutes } from './routes/me'; import { realtimeRoutes } from './routes/realtime'; +import { safetyRoutes } from './routes/safety'; import { trackRoutes } from './routes/tracks'; import { userRoutes } from './routes/users'; import { webhookRoutes } from './routes/webhooks'; @@ -55,6 +57,8 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?: await app.register(authRoutes); await app.register(meRoutes); await app.register(userRoutes); + await app.register(safetyRoutes); + await app.register(adminRoutes); await app.register(trackRoutes); await app.register(connectionRoutes); await app.register(deviceRoutes); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index e5ed5b6..4d2705a 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -12,7 +12,7 @@ import { uuid, varchar, } from 'drizzle-orm/pg-core'; -import type { NowPlayingProvider, Plan, StreamingService } from '@musicshare/shared'; +import type { NowPlayingProvider, Plan, ReportReason, StreamingService } from '@musicshare/shared'; const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow(); @@ -26,6 +26,12 @@ export const users = pgTable('users', { avatarUrl: varchar('avatar_url', { length: 500 }), preferredService: text('preferred_service').$type(), /** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */ + /** The first account on a server becomes admin (moderation). */ + role: text('role').$type<'user' | 'admin'>().notNull().default('user'), + /** Suspended by a moderator: can't sign in, profile hidden, sharing stopped. */ + suspendedAt: timestamp('suspended_at', { withTimezone: true }), + /** Set when the user finishes (or skips through) onboarding; null shows it on next launch. */ + onboardedAt: timestamp('onboarded_at', { withTimezone: true }), /** While in the future, nothing new is shared: services aren't polled and phone reports are dropped. */ sharingPausedUntil: timestamp('sharing_paused_until', { withTimezone: true }), plan: text('plan').$type().notNull().default('free'), @@ -63,6 +69,38 @@ export const follows = pgTable( (t) => [primaryKey({ columns: [t.followerId, t.followeeId] }), index('follows_followee_idx').on(t.followeeId)], ); +/** Blocking removes follows both ways and hides each user from the other. */ +export const blocks = pgTable( + 'blocks', + { + blockerId: uuid('blocker_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + blockedId: uuid('blocked_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + createdAt: createdAt(), + }, + (t) => [primaryKey({ columns: [t.blockerId, t.blockedId] }), index('blocks_blocked_idx').on(t.blockedId)], +); + +/** User reports for moderation (App Store guideline 1.2 requires a way to report users). */ +export const reports = pgTable( + 'reports', + { + id: uuid('id').primaryKey().defaultRandom(), + reporterId: uuid('reporter_id').references(() => users.id, { onDelete: 'set null' }), + reportedId: uuid('reported_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + reason: text('reason').$type().notNull(), + note: varchar('note', { length: 500 }), + status: text('status').$type<'open' | 'resolved'>().notNull().default('open'), + createdAt: createdAt(), + }, + (t) => [index('reports_status_idx').on(t.status)], +); + export const connections = pgTable( 'connections', { diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index aff7309..d0df3db 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -43,12 +43,18 @@ export async function loadUser(req: FastifyRequest) { .from(schema.sessions) .innerJoin(schema.users, eq(schema.users.id, schema.sessions.userId)) .where(and(eq(schema.sessions.id, id), gt(schema.sessions.expiresAt, new Date()))); - if (row) { + if (row && !row.user.suspendedAt) { req.user = row.user; req.sessionId = id; } } +export function requireAdmin(req: FastifyRequest): User { + const user = requireUser(req); + if (user.role !== 'admin') throw new HttpError(403, 'Admins only'); + return user; +} + export function requireUser(req: FastifyRequest): User { if (!req.user) throw new HttpError(401, 'Not signed in'); return req.user; diff --git a/apps/api/src/providers/index.ts b/apps/api/src/providers/index.ts index fcff07d..5241568 100644 --- a/apps/api/src/providers/index.ts +++ b/apps/api/src/providers/index.ts @@ -4,12 +4,10 @@ import { jellyfinProvider } from './jellyfin'; import { lastfmProvider } from './lastfm'; import { listenbrainzProvider } from './listenbrainz'; import { plexProvider } from './plex'; -import { spotifyProvider } from './spotify'; import type { PollingProvider } from './types'; /** Providers the server polls. Push-only sources (the Android app) don't appear here. */ export const pollingProviders: Partial> = { - spotify: spotifyProvider, lastfm: lastfmProvider, listenbrainz: listenbrainzProvider, plex: plexProvider, diff --git a/apps/api/src/providers/spotify.ts b/apps/api/src/providers/spotify.ts index e327311..6635e5f 100644 --- a/apps/api/src/providers/spotify.ts +++ b/apps/api/src/providers/spotify.ts @@ -1,15 +1,14 @@ -import { eq } from 'drizzle-orm'; -import { db, schema } from '../db'; -import type { Connection } from '../db/schema'; +/** + * Spotify catalog lookups only (app-level client credentials, not users' accounts), + * used to give "Open in Spotify" an exact link. Spotify listening itself comes in + * through Spotify's own Last.fm / ListenBrainz links, or the Android reader: + * Spotify caps new apps at 5 user accounts, so we don't ask users to sign in here. + */ import { env } from '../env'; -import { decrypt, encrypt } from '../lib/crypto'; -import { HttpError, fetchJson } from '../lib/http'; +import { HttpError } from '../lib/http'; import { redis } from '../lib/redis'; import { findBestMatch } from './match'; -import { ProviderAuthError, ProviderRateLimitError, type IncomingTrack, type PollingProvider } from './types'; - -const SCOPES = ['user-read-currently-playing', 'user-read-playback-state']; -export const redirectUri = () => `${env.API_PUBLIC_URL}/connections/spotify/callback`; +import { ProviderAuthError, ProviderRateLimitError, type IncomingTrack } from './types'; interface TokenResponse { access_token: string; @@ -30,17 +29,6 @@ interface SpotifyTrack { const basicAuth = () => 'Basic ' + Buffer.from(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`).toString('base64'); -export function authorizeUrl(state: string): string { - const params = new URLSearchParams({ - client_id: env.SPOTIFY_CLIENT_ID!, - response_type: 'code', - redirect_uri: redirectUri(), - scope: SCOPES.join(' '), - state, - }); - return `https://accounts.spotify.com/authorize?${params}`; -} - async function tokenRequest(body: Record): Promise { const res = await fetch('https://accounts.spotify.com/api/token', { method: 'POST', @@ -53,33 +41,6 @@ async function tokenRequest(body: Record): Promise - tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: redirectUri() }); - -export async function getProfile(accessToken: string) { - return (await fetchJson<{ id: string; display_name: string | null }>('https://api.spotify.com/v1/me', { - headers: { Authorization: `Bearer ${accessToken}` }, - }))!; -} - -/** Returns a valid access token for the connection, refreshing and persisting if needed. */ -async function userAccessToken(conn: Connection): Promise { - if (conn.accessTokenEnc && conn.tokenExpiresAt && conn.tokenExpiresAt.getTime() > Date.now() + 60_000) { - return decrypt(conn.accessTokenEnc); - } - if (!conn.refreshTokenEnc) throw new ProviderAuthError('no refresh token'); - const tok = await tokenRequest({ grant_type: 'refresh_token', refresh_token: decrypt(conn.refreshTokenEnc) }); - await db - .update(schema.connections) - .set({ - accessTokenEnc: encrypt(tok.access_token), - refreshTokenEnc: tok.refresh_token ? encrypt(tok.refresh_token) : conn.refreshTokenEnc, - tokenExpiresAt: new Date(Date.now() + tok.expires_in * 1000), - }) - .where(eq(schema.connections.id, conn.id)); - return tok.access_token; -} - async function spotifyGet(path: string, token: string): Promise { const res = await fetch(`https://api.spotify.com/v1${path}`, { headers: { Authorization: `Bearer ${token}` }, @@ -104,24 +65,6 @@ function toIncoming(t: SpotifyTrack): IncomingTrack { }; } -export const spotifyProvider: PollingProvider = { - id: 'spotify', - async poll(conn) { - const token = await userAccessToken(conn); - const data = await spotifyGet<{ - is_playing: boolean; - progress_ms: number | null; - currently_playing_type: string; - item: SpotifyTrack | null; - }>('/me/player/currently-playing', token); - // Podcasts/ads come back with a null or non-track item. - if (!data?.item || data.currently_playing_type !== 'track') return null; - return { track: toIncoming(data.item), isPlaying: data.is_playing, progressMs: data.progress_ms }; - }, -}; - -// ---- catalog lookups (client credentials), used for cross-service linking ---- - async function appAccessToken(): Promise { const cached = await redis.get('spotify:app-token'); if (cached) return cached; diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts new file mode 100644 index 0000000..16652f0 --- /dev/null +++ b/apps/api/src/routes/admin.ts @@ -0,0 +1,75 @@ +/** Moderation for admins: the report queue, suspensions, and a few numbers. */ +import { and, count, desc, eq, isNotNull } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; +import type { FastifyInstance } from 'fastify'; +import type { AdminReportDTO, AdminStatsDTO } from '@musicshare/shared'; +import { db, schema } from '../db'; +import { requireAdmin } from '../lib/auth'; +import { HttpError } from '../lib/http'; +import { pauseSharing, toUserSummary } from '../services/nowPlaying'; + +export async function adminRoutes(app: FastifyInstance) { + app.get('/admin/stats', async (req): Promise => { + requireAdmin(req); + const [[users], [listening], [open], [suspended]] = await Promise.all([ + db.select({ n: count() }).from(schema.users), + db.select({ n: count() }).from(schema.nowPlaying).where(eq(schema.nowPlaying.isPlaying, true)), + db.select({ n: count() }).from(schema.reports).where(eq(schema.reports.status, 'open')), + db.select({ n: count() }).from(schema.users).where(isNotNull(schema.users.suspendedAt)), + ]); + return { users: users!.n, listeningNow: listening!.n, openReports: open!.n, suspended: suspended!.n }; + }); + + app.get<{ Querystring: { status?: 'open' | 'resolved' } }>('/admin/reports', async (req): Promise => { + requireAdmin(req); + const reporter = alias(schema.users, 'reporter'); + const reported = alias(schema.users, 'reported'); + const rows = await db + .select({ report: schema.reports, reporter, reported }) + .from(schema.reports) + .leftJoin(reporter, eq(reporter.id, schema.reports.reporterId)) + .innerJoin(reported, eq(reported.id, schema.reports.reportedId)) + .where(eq(schema.reports.status, req.query.status === 'resolved' ? 'resolved' : 'open')) + .orderBy(desc(schema.reports.createdAt)) + .limit(200); + return rows.map(({ report, reporter: by, reported: who }) => ({ + id: report.id, + reason: report.reason, + note: report.note, + status: report.status, + createdAt: report.createdAt.toISOString(), + reporter: by ? toUserSummary(by) : null, + reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) }, + })); + }); + + app.post<{ Params: { id: string } }>('/admin/reports/:id/resolve', async (req) => { + requireAdmin(req); + if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) throw new HttpError(404, 'Report not found'); + await db.update(schema.reports).set({ status: 'resolved' }).where(eq(schema.reports.id, req.params.id)); + return { ok: true }; + }); + + /** Suspend: signs them out everywhere, blocks login, hides their profile, stops sharing. */ + app.post<{ Params: { username: string } }>('/admin/users/:username/suspend', async (req) => { + const admin = requireAdmin(req); + const [user] = await db.select().from(schema.users).where(eq(schema.users.username, req.params.username.toLowerCase())); + if (!user) throw new HttpError(404, 'User not found'); + if (user.id === admin.id) throw new HttpError(400, "You can't suspend yourself"); + if (user.role === 'admin') throw new HttpError(400, "Admins can't be suspended"); + await db.update(schema.users).set({ suspendedAt: new Date() }).where(eq(schema.users.id, user.id)); + await db.delete(schema.sessions).where(eq(schema.sessions.userId, user.id)); + await pauseSharing(user.id); + req.log.warn({ by: admin.username, user: user.username }, 'user suspended'); + return { ok: true }; + }); + + app.delete<{ Params: { username: string } }>('/admin/users/:username/suspend', async (req) => { + requireAdmin(req); + await db + .update(schema.users) + .set({ suspendedAt: null }) + .where(and(eq(schema.users.username, req.params.username.toLowerCase()), isNotNull(schema.users.suspendedAt))); + return { ok: true }; + }); +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 7381e82..7582a0a 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,5 +1,5 @@ import { hash, verify } from '@node-rs/argon2'; -import { eq, or } from 'drizzle-orm'; +import { count, eq, or, sql } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { loginSchema, registerSchema } from '@musicshare/shared'; import { db, schema } from '../db'; @@ -18,10 +18,17 @@ export async function authRoutes(app: FastifyInstance) { .where(or(eq(schema.users.username, input.username), eq(schema.users.email, input.email))); if (taken) throw new HttpError(409, taken.email === input.email ? 'Email already registered' : 'Username taken'); - const [user] = await db - .insert(schema.users) - .values({ username: input.username, email: input.email, passwordHash: await hash(input.password) }) - .returning(); + const passwordHash = await hash(input.password); + const user = await db.transaction(async (tx) => { + // Serialize sign-ups so two simultaneous "first" accounts can't both become admin. + await tx.execute(sql`select pg_advisory_xact_lock(4242)`); + 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' }) + .returning(); + return created; + }); const token = await createSession(user!.id, reply); return { user: toMeDTO(user!), token }; }); @@ -32,6 +39,7 @@ export async function authRoutes(app: FastifyInstance) { if (!user || !(await verify(user.passwordHash, input.password))) { throw new HttpError(401, 'Invalid email or password'); } + if (user.suspendedAt) throw new HttpError(403, 'This account has been suspended'); const token = await createSession(user.id, reply); return { user: toMeDTO(user), token }; }); diff --git a/apps/api/src/routes/connections.ts b/apps/api/src/routes/connections.ts index 69b30c8..d678b31 100644 --- a/apps/api/src/routes/connections.ts +++ b/apps/api/src/routes/connections.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared'; import { db, schema } from '../db'; import type { User } from '../db/schema'; -import { appleMusicConfigured, env, spotifyConfigured } from '../env'; +import { appleMusicConfigured, env } from '../env'; import { requireUser } from '../lib/auth'; import { encrypt, randomToken } from '../lib/crypto'; import { entitlementsFor } from '../lib/entitlements'; @@ -17,7 +17,6 @@ import { lastfmAuthUrl, lastfmSession, lastfmUserExists } from '../providers/las import { ProviderAuthError } from '../providers/types'; import { listenbrainzUserExists } from '../providers/listenbrainz'; import * as plex from '../providers/plex'; -import * as spotify from '../providers/spotify'; import { toConnectionDTO, upsertConnection } from '../services/connections'; import { unschedulePoll } from '../services/scheduler'; @@ -55,58 +54,6 @@ export async function connectionRoutes(app: FastifyInstance) { return rows.map(toConnectionDTO); }); - // ---- Spotify (OAuth) ---- - - app.post('/connections/spotify/authorize', async (req) => { - const user = requireUser(req); - if (!spotifyConfigured) throw new HttpError(501, 'Spotify is not configured on this server'); - await assertCanConnect(user, 'spotify'); - const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {})); - const state = randomToken(24); - await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo }), 'EX', 600); - return { url: spotify.authorizeUrl(state) }; - }); - - // The callback is identified by `state`, not the session cookie: Spotify - // requires a 127.0.0.1 redirect in dev, which won't carry localhost cookies. - app.get<{ Querystring: { code?: string; state?: string; error?: string } }>( - '/connections/spotify/callback', - async (req, reply) => { - const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null; - if (!raw) throw new HttpError(400, 'Invalid or expired OAuth state'); - const { userId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string }; - const returnTo = new URL(state.returnTo); - - if (req.query.error || !req.query.code) { - returnTo.searchParams.set('error', req.query.error ?? 'spotify_denied'); - return reply.redirect(returnTo.toString()); - } - const tok = await spotify.exchangeCode(req.query.code); - let profile; - try { - profile = await spotify.getProfile(tok.access_token); - } catch (err) { - // Development-mode apps reject accounts not on the allowlist with 403. - if (err instanceof HttpError && err.status === 403) { - returnTo.searchParams.set('error', 'spotify_limited'); - return reply.redirect(returnTo.toString()); - } - throw err; - } - await upsertConnection({ - userId, - provider: 'spotify', - externalUserId: profile.id, - displayName: profile.display_name, - accessTokenEnc: encrypt(tok.access_token), - refreshTokenEnc: tok.refresh_token ? encrypt(tok.refresh_token) : null, - tokenExpiresAt: new Date(Date.now() + tok.expires_in * 1000), - }); - returnTo.searchParams.set('connected', 'spotify'); - return reply.redirect(returnTo.toString()); - }, - ); - // ---- Plex (PIN flow) ---- app.post('/connections/plex/authorize', async (req) => { diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index bce7418..469353c 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -21,6 +21,8 @@ export const toMeDTO = (u: User): MeDTO => ({ email: u.email, bio: u.bio, preferredService: u.preferredService, + role: u.role, + onboardedAt: u.onboardedAt?.toISOString() ?? null, sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null, plan: effectivePlan(u), entitlements: entitlementsFor(u), @@ -31,8 +33,10 @@ export async function meRoutes(app: FastifyInstance) { app.patch('/me', async (req) => { const user = requireUser(req); - const input = updateMeSchema.parse(req.body); - const [updated] = await db.update(schema.users).set(input).where(eq(schema.users.id, user.id)).returning(); + const { onboarded, ...input } = updateMeSchema.parse(req.body); + 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(); return toMeDTO(updated!); }); diff --git a/apps/api/src/routes/safety.ts b/apps/api/src/routes/safety.ts new file mode 100644 index 0000000..f77bded --- /dev/null +++ b/apps/api/src/routes/safety.ts @@ -0,0 +1,84 @@ +/** + * Blocking, removing followers, and reporting. App Store guideline 1.2 requires + * apps with user content to let people block and report abusive users. + */ +import { and, desc, eq, or } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { reportSchema } from '@musicshare/shared'; +import { db, schema } from '../db'; +import { requireUser } from '../lib/auth'; +import { HttpError } from '../lib/http'; +import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis'; +import { toUserSummary } from '../services/nowPlaying'; + +async function findUser(username: string) { + const [user] = await db.select().from(schema.users).where(eq(schema.users.username, username.toLowerCase())); + if (!user) throw new HttpError(404, 'User not found'); + return user; +} + +/** Live websocket subscriptions are built from follows; refresh them for both people. */ +const followsChanged = (...userIds: string[]) => Promise.all(userIds.map((id) => redis.publish(FOLLOWS_CHANGED_CHANNEL, id))); + +export async function safetyRoutes(app: FastifyInstance) { + /** Remove someone who follows you. They aren't notified, and can follow again (block to prevent that). */ + app.delete<{ Params: { username: string } }>('/me/followers/:username', async (req) => { + const me = requireUser(req); + const them = await findUser(req.params.username); + await db.delete(schema.follows).where(and(eq(schema.follows.followerId, them.id), eq(schema.follows.followeeId, me.id))); + await followsChanged(them.id); + return { ok: true }; + }); + + app.post<{ Params: { username: string } }>('/users/:username/block', async (req) => { + const me = requireUser(req); + const them = await findUser(req.params.username); + if (them.id === me.id) throw new HttpError(400, "You can't block yourself"); + await db.transaction(async (tx) => { + await tx.insert(schema.blocks).values({ blockerId: me.id, blockedId: them.id }).onConflictDoNothing(); + // Sever follows in both directions (pins go with them). + await tx + .delete(schema.follows) + .where( + or( + and(eq(schema.follows.followerId, me.id), eq(schema.follows.followeeId, them.id)), + and(eq(schema.follows.followerId, them.id), eq(schema.follows.followeeId, me.id)), + ), + ); + }); + await followsChanged(me.id, them.id); + return { ok: true }; + }); + + app.delete<{ Params: { username: string } }>('/users/:username/block', async (req) => { + const me = requireUser(req); + const them = await findUser(req.params.username); + await db.delete(schema.blocks).where(and(eq(schema.blocks.blockerId, me.id), eq(schema.blocks.blockedId, them.id))); + return { ok: true }; + }); + + app.get('/me/blocks', async (req) => { + const me = requireUser(req); + const rows = await db + .select({ user: schema.users }) + .from(schema.blocks) + .innerJoin(schema.users, eq(schema.users.id, schema.blocks.blockedId)) + .where(eq(schema.blocks.blockerId, me.id)) + .orderBy(desc(schema.blocks.createdAt)); + return rows.map((r) => toUserSummary(r.user)); + }); + + app.post<{ Params: { username: string } }>( + '/users/:username/report', + { config: { rateLimit: { max: 10, timeWindow: '1 hour' } } }, + async (req) => { + const me = requireUser(req); + const them = await findUser(req.params.username); + if (them.id === me.id) throw new HttpError(400, "You can't report yourself"); + const input = reportSchema.parse(req.body); + await db.insert(schema.reports).values({ reporterId: me.id, reportedId: them.id, reason: input.reason, note: input.note || null }); + req.log.warn({ reported: them.username, reason: input.reason }, 'user reported'); + return { ok: true }; + }, + ); +} diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index 99076a2..8bebb62 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -1,11 +1,12 @@ -import { and, count, eq, ilike, inArray, isNotNull, or } from 'drizzle-orm'; +import { and, count, desc, eq, ilike, inArray, isNotNull, isNull, or } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; -import { sortFeed, sourceProfileUrl, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared'; +import { sortFeed, sourceProfileUrl, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared'; import { db, schema } from '../db'; import { requireUser } from '../lib/auth'; import { entitlementsFor } from '../lib/entitlements'; import { HttpError } from '../lib/http'; import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis'; +import { blockStatus, hiddenUserIds } from '../services/blocks'; import { watchUsers } from '../services/connections'; import { getNowPlaying, getRecentPlays, toUserSummary } from '../services/nowPlaying'; import { getTrackDTOs } from '../services/tracks'; @@ -16,22 +17,41 @@ async function findByUsername(username: string) { return user; } +/** Someone who blocked you looks like they don't exist. */ +async function findVisible(username: string, viewerId: string | undefined, viewerIsAdmin = false) { + const user = await findByUsername(username); + if (user.suspendedAt && !viewerIsAdmin) throw new HttpError(404, 'User not found'); + const status = await blockStatus(viewerId, user.id); + if (status.blockedByThem) throw new HttpError(404, 'User not found'); + return { user, ...status }; +} + +const PAGE = 50; + export async function userRoutes(app: FastifyInstance) { app.get<{ Querystring: { q?: string } }>('/users/search', async (req) => { const q = (req.query.q ?? '').trim().replace(/[%_\\]/g, ''); if (q.length < 2) return []; - const rows = await db - .select() - .from(schema.users) - .where(or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`))) - .limit(20); - return rows.map(toUserSummary); + const [rows, hidden] = await Promise.all([ + db + .select() + .from(schema.users) + .where( + and( + isNull(schema.users.suspendedAt), + or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`)), + ), + ) + .limit(30), + hiddenUserIds(req.user?.id), + ]); + return rows.filter((u) => !hidden.has(u.id)).slice(0, 20).map(toUserSummary); }); app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise => { - const user = await findByUsername(req.params.username); const viewer = req.user; - const [[followers], [following], [follow], [featured], nowPlaying, recent, scrobblers] = await Promise.all([ + const { user, viewerBlocked } = await findVisible(req.params.username, viewer?.id, viewer?.role === 'admin'); + const [[followers], [following], [follow], [followsYou], [featured], nowPlaying, recent, scrobblers] = await Promise.all([ db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followeeId, user.id)), db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)), viewer @@ -40,6 +60,12 @@ export async function userRoutes(app: FastifyInstance) { .from(schema.follows) .where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, user.id))) : Promise.resolve([]), + viewer + ? db + .select({ x: schema.follows.followerId }) + .from(schema.follows) + .where(and(eq(schema.follows.followerId, user.id), eq(schema.follows.followeeId, viewer.id))) + : Promise.resolve([]), db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)), getNowPlaying([user.id]), getRecentPlays(user.id), @@ -54,7 +80,7 @@ export async function userRoutes(app: FastifyInstance) { if (url && (c.provider === 'lastfm' || c.provider === 'listenbrainz')) attribution[c.provider] = url; } const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined; - await watchUsers([user.id]); + if (!viewerBlocked) await watchUsers([user.id]); return { user: { ...toUserSummary(user), bio: user.bio }, @@ -63,20 +89,24 @@ export async function userRoutes(app: FastifyInstance) { isFollowing: Boolean(follow), isPinned: Boolean(follow?.pinnedAt), isSelf: viewer?.id === user.id, + followsYou: Boolean(followsYou), + isBlocked: viewerBlocked, + // You blocked them: don't show their activity (until you unblock). featured: - featured && featuredTrack + !viewerBlocked && featured && featuredTrack ? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() } : null, - nowPlaying: nowPlaying[0] ?? null, - recent, + nowPlaying: viewerBlocked ? null : (nowPlaying[0] ?? null), + recent: viewerBlocked ? [] : recent, attribution, }; }); app.post<{ Params: { username: string } }>('/users/:username/follow', async (req) => { const viewer = requireUser(req); - const target = await findByUsername(req.params.username); + const { user: target, viewerBlocked } = await findVisible(req.params.username, viewer.id); if (target.id === viewer.id) throw new HttpError(400, "You can't follow yourself"); + if (viewerBlocked) throw new HttpError(400, 'Unblock them first'); await db.insert(schema.follows).values({ followerId: viewer.id, followeeId: target.id }).onConflictDoNothing(); await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id); return { ok: true }; @@ -124,6 +154,46 @@ export async function userRoutes(app: FastifyInstance) { return { ok: true }; }); + /** Who follows someone, or who they follow. Newest first, 50 per page. */ + for (const direction of ['followers', 'following'] as const) { + app.get<{ Params: { username: string }; Querystring: { cursor?: string } }>( + `/users/:username/${direction}`, + async (req): Promise => { + const viewer = req.user; + const { user } = await findVisible(req.params.username, viewer?.id); + const offset = Math.max(0, Number.parseInt(req.query.cursor ?? '0', 10) || 0); + const [listUser, matchCol] = + direction === 'followers' + ? [schema.follows.followerId, schema.follows.followeeId] + : [schema.follows.followeeId, schema.follows.followerId]; + const rows = await db + .select({ user: schema.users }) + .from(schema.follows) + .innerJoin(schema.users, eq(schema.users.id, listUser)) + .where(eq(matchCol, user.id)) + .orderBy(desc(schema.follows.createdAt)) + .limit(PAGE + 1) + .offset(offset); + const hidden = await hiddenUserIds(viewer?.id); + const page = rows.slice(0, PAGE).map((r) => r.user); + const viewerFollows = viewer && page.length + ? new Set( + ( + await db + .select({ id: schema.follows.followeeId }) + .from(schema.follows) + .where(and(eq(schema.follows.followerId, viewer.id), inArray(schema.follows.followeeId, page.map((u) => u.id)))) + ).map((r) => r.id), + ) + : new Set(); + return { + users: page.filter((u) => !hidden.has(u.id)).map((u) => ({ ...toUserSummary(u), isFollowing: viewerFollows.has(u.id) })), + nextCursor: rows.length > PAGE ? String(offset + PAGE) : null, + }; + }, + ); + } + /** Now-playing state for everyone the viewer follows, pinned friends first. */ app.get('/feed', async (req): Promise => { const viewer = requireUser(req); diff --git a/apps/api/src/scripts/set-role.ts b/apps/api/src/scripts/set-role.ts new file mode 100644 index 0000000..6be37e5 --- /dev/null +++ b/apps/api/src/scripts/set-role.ts @@ -0,0 +1,22 @@ +/** + * Promote or demote an account: + * npm run admin:promote -w @musicshare/api -- + * npm run admin:demote -w @musicshare/api -- + * In Docker: docker compose exec api node dist/scripts/set-role.js admin + */ +import { eq } from 'drizzle-orm'; +import { db, schema, sql } from '../db'; + +const [role, username] = process.argv.slice(2); +if ((role !== 'admin' && role !== 'user') || !username) { + console.error('usage: set-role '); + process.exit(1); +} +const updated = await db + .update(schema.users) + .set({ role }) + .where(eq(schema.users.username, username.toLowerCase())) + .returning({ username: schema.users.username }); +console.log(updated.length ? `${updated[0]!.username} is now ${role}` : `no user named ${username}`); +await sql.end(); +process.exit(updated.length ? 0 : 1); diff --git a/apps/api/src/services/blocks.ts b/apps/api/src/services/blocks.ts new file mode 100644 index 0000000..3945f34 --- /dev/null +++ b/apps/api/src/services/blocks.ts @@ -0,0 +1,30 @@ +import { and, eq, or } from 'drizzle-orm'; +import { db, schema } from '../db'; + +/** Block relationship between a viewer and another user, in both directions. */ +export async function blockStatus(viewerId: string | undefined, otherId: string) { + if (!viewerId || viewerId === otherId) return { viewerBlocked: false, blockedByThem: false }; + const rows = await db + .select() + .from(schema.blocks) + .where( + or( + and(eq(schema.blocks.blockerId, viewerId), eq(schema.blocks.blockedId, otherId)), + and(eq(schema.blocks.blockerId, otherId), eq(schema.blocks.blockedId, viewerId)), + ), + ); + return { + viewerBlocked: rows.some((r) => r.blockerId === viewerId), + blockedByThem: rows.some((r) => r.blockerId === otherId), + }; +} + +/** Everyone the viewer shouldn't see in lists or search: people they blocked and people who blocked them. */ +export async function hiddenUserIds(viewerId: string | undefined): Promise> { + if (!viewerId) return new Set(); + const rows = await db + .select() + .from(schema.blocks) + .where(or(eq(schema.blocks.blockerId, viewerId), eq(schema.blocks.blockedId, viewerId))); + return new Set(rows.map((r) => (r.blockerId === viewerId ? r.blockedId : r.blockerId))); +} diff --git a/apps/api/src/services/nowPlaying.ts b/apps/api/src/services/nowPlaying.ts index 488fe30..17bcb6c 100644 --- a/apps/api/src/services/nowPlaying.ts +++ b/apps/api/src/services/nowPlaying.ts @@ -74,8 +74,9 @@ export async function getRecentPlays(userId: string, limit = 10): Promise ({ track: tracks.get(p.trackId)!, source: p.source, playedAt: p.startedAt.toISOString() })); } -export const isSharingPaused = (user: Pick) => - Boolean(user.sharingPausedUntil && user.sharingPausedUntil > new Date()); +/** Paused by the user, or suspended by a moderator: nothing new is shared either way. */ +export const isSharingPaused = (user: Pick & Partial>) => + Boolean(user.suspendedAt) || Boolean(user.sharingPausedUntil && user.sharingPausedUntil > new Date()); /** Starting a pause: whatever was playing becomes "played … ago" for friends. */ export async function pauseSharing(userId: string) { diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 85a2307..cc384e8 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -37,7 +37,7 @@ async function pollConnection(connectionId: string) { if (isSharingPaused(user)) { // Don't even ask the provider while paused; check back when the pause ends. - const resumeIn = user.sharingPausedUntil!.getTime() - Date.now(); + const resumeIn = user.sharingPausedUntil ? user.sharingPausedUntil.getTime() - Date.now() : IDLE_POLL_SECONDS * 1000; await schedulePoll(conn.id, Math.min(resumeIn, IDLE_POLL_SECONDS * 1000)); return; } diff --git a/apps/api/test/admin.test.ts b/apps/api/test/admin.test.ts new file mode 100644 index 0000000..40d9e99 --- /dev/null +++ b/apps/api/test/admin.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { applyPollResult } from '../src/services/nowPlaying'; +import { bearer, createUser, useTestApp } from './helpers'; + +const app = useTestApp(); +const get = (url: string, token?: string) => app().inject({ url, headers: token ? bearer(token) : {} }); +const post = (url: string, token: string, payload: object = {}) => app().inject({ method: 'POST', url, headers: bearer(token), payload }); + +describe('admin', () => { + it('makes the first account on a server the admin, and only the first', async () => { + const first = await createUser(app(), 'founder'); + const second = await createUser(app(), 'second'); + expect(first.user.role).toBe('admin'); + expect(second.user.role).toBe('user'); + }); + + it('keeps admin endpoints away from regular users', async () => { + await createUser(app(), 'founder'); + const user = await createUser(app(), 'regular'); + expect((await get('/admin/stats', user.token)).statusCode).toBe(403); + expect((await get('/admin/reports', user.token)).statusCode).toBe(403); + expect((await get('/admin/stats')).statusCode).toBe(401); + }); + + it('shows the report queue and resolves reports', async () => { + const admin = await createUser(app(), 'founder'); + const alice = await createUser(app(), 'alice'); + await createUser(app(), 'spammer'); + await post('/users/spammer/report', alice.token, { reason: 'spam', note: 'links everywhere' }); + + const queue = (await get('/admin/reports', admin.token)).json(); + expect(queue).toHaveLength(1); + expect(queue[0]).toMatchObject({ reason: 'spam', note: 'links everywhere', reporter: { username: 'alice' }, reported: { username: 'spammer', suspended: false } }); + expect((await get('/admin/stats', admin.token)).json()).toMatchObject({ users: 3, openReports: 1 }); + + await post(`/admin/reports/${queue[0].id}/resolve`, admin.token); + expect((await get('/admin/reports', admin.token)).json()).toHaveLength(0); + expect((await get('/admin/reports?status=resolved', admin.token)).json()).toHaveLength(1); + }); + + it('suspension signs the user out, blocks login, hides them, and stops sharing', async () => { + const admin = await createUser(app(), 'founder'); + const bad = await createUser(app(), 'troll'); + const viewer = await createUser(app(), 'viewer'); + await applyPollResult(bad.user.id, 'spotify', { track: { title: 'Loud', artist: 'X' }, isPlaying: true, progressMs: 0 }); + + expect((await post('/admin/users/troll/suspend', admin.token)).statusCode).toBe(200); + expect((await get('/me', bad.token)).statusCode).toBe(401); + const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'troll@example.com', password: 'password123' } }); + expect(login.statusCode).toBe(403); + expect((await get('/users/troll', viewer.token)).statusCode).toBe(404); + expect((await get('/users/search?q=tro', viewer.token)).json()).toEqual([]); + // Admins can still look them up. + expect((await get('/users/troll', admin.token)).json().nowPlaying.isPlaying).toBe(false); + + await app().inject({ method: 'DELETE', url: '/admin/users/troll/suspend', headers: bearer(admin.token) }); + const again = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'troll@example.com', password: 'password123' } }); + expect(again.statusCode).toBe(200); + }); + + it("can't suspend yourself or another admin", async () => { + const admin = await createUser(app(), 'founder'); + expect((await post('/admin/users/founder/suspend', admin.token)).statusCode).toBe(400); + }); +}); diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts index 3b22113..a9fa98d 100644 --- a/apps/api/test/auth.test.ts +++ b/apps/api/test/auth.test.ts @@ -92,3 +92,14 @@ describe('auth', () => { expect((await app().inject({ url: '/users/bob', headers: bearer(bob.token) })).json().followerCount).toBe(0); }); }); + +describe('onboarding flag', () => { + it('new accounts start un-onboarded; finishing sets it once', async () => { + const { token, user } = await createUser(app(), 'newbie'); + expect(user.onboardedAt).toBeNull(); + const first = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload: { onboarded: true } })).json(); + expect(first.onboardedAt).toEqual(expect.any(String)); + const again = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload: { onboarded: true } })).json(); + expect(again.onboardedAt).toBe(first.onboardedAt); + }); +}); diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts index bba0880..cda90f8 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 restart identity cascade`; + await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports restart identity cascade`; await redis.flushdb(); } diff --git a/apps/api/test/oauth.test.ts b/apps/api/test/oauth.test.ts index a7291a9..cd353ef 100644 --- a/apps/api/test/oauth.test.ts +++ b/apps/api/test/oauth.test.ts @@ -61,27 +61,3 @@ describe('Last.fm sign-in', () => { expect((await app().inject({ url: '/connections/lastfm/callback?state=forged&token=x' })).statusCode).toBe(400); }); }); - -describe('Spotify sign-in during the limited beta', () => { - it('sends users who are not on the allowlist back with a friendly error instead of failing', async () => { - vi.stubGlobal( - 'fetch', - routeFetch([ - [/accounts\.spotify\.com\/api\/token/, () => json({ access_token: 'a', refresh_token: 'r', expires_in: 3600 })], - [/api\.spotify\.com\/v1\/me$/, () => json({ error: { status: 403, message: 'User not registered in the Developer Dashboard' } }, 403)], - ]), - ); - const { token } = await createUser(app(), 'alice'); - const start = await app().inject({ - method: 'POST', - url: '/connections/spotify/authorize', - headers: bearer(token), - payload: { client: 'mobile', returnTo: 'musicshare://settings' }, - }); - const state = new URL(start.json().url).searchParams.get('state'); - const done = await app().inject({ url: `/connections/spotify/callback?state=${state}&code=abc` }); - expect(done.statusCode).toBe(302); - expect(done.headers.location).toBe('musicshare://settings?error=spotify_limited'); - expect(await db.select().from(schema.connections)).toHaveLength(0); - }); -}); diff --git a/apps/api/test/safety.test.ts b/apps/api/test/safety.test.ts new file mode 100644 index 0000000..1511586 --- /dev/null +++ b/apps/api/test/safety.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { db, schema } from '../src/db'; +import { applyPollResult } from '../src/services/nowPlaying'; +import { bearer, createUser, follow, useTestApp } from './helpers'; + +const app = useTestApp(); + +const get = (url: string, token?: string) => app().inject({ url, headers: token ? bearer(token) : {} }); +const post = (url: string, token: string, payload: object = {}) => app().inject({ method: 'POST', url, headers: bearer(token), payload }); +const del = (url: string, token: string) => app().inject({ method: 'DELETE', url, headers: bearer(token) }); +const names = (list: { users: { username: string }[] }) => list.users.map((u) => u.username); + +describe('followers and following lists', () => { + it('lists followers and following, newest first, with the viewer’s follow state', async () => { + const alice = await createUser(app(), 'alice'); + const bob = await createUser(app(), 'bob'); + const carol = await createUser(app(), 'carol'); + await follow(app(), bob.token, 'alice'); + await follow(app(), carol.token, 'alice'); + await follow(app(), alice.token, 'carol'); + + const followers = (await get('/users/alice/followers', alice.token)).json(); + expect(names(followers)).toEqual(['carol', 'bob']); + expect(followers.users.find((u: { username: string }) => u.username === 'carol').isFollowing).toBe(true); + expect(followers.nextCursor).toBeNull(); + expect(names((await get('/users/alice/following')).json())).toEqual(['carol']); + }); + + it('paginates', { timeout: 60_000 }, async () => { + const alice = await createUser(app(), 'alice'); + for (let i = 0; i < 55; i++) { + const f = await createUser(app(), `fan${i}`); + await follow(app(), f.token, 'alice'); + } + const first = (await get('/users/alice/followers', alice.token)).json(); + expect(first.users).toHaveLength(50); + const second = (await get(`/users/alice/followers?cursor=${first.nextCursor}`, alice.token)).json(); + expect(second.users).toHaveLength(5); + expect(second.nextCursor).toBeNull(); + }); + + it('shows "follows you" on profiles', async () => { + const alice = await createUser(app(), 'alice'); + const bob = await createUser(app(), 'bob'); + await follow(app(), bob.token, 'alice'); + expect((await get('/users/bob', alice.token)).json().followsYou).toBe(true); + expect((await get('/users/alice', bob.token)).json().followsYou).toBe(false); + }); + + it('removing a follower takes them off your list', async () => { + const alice = await createUser(app(), 'alice'); + const bob = await createUser(app(), 'bob'); + await follow(app(), bob.token, 'alice'); + expect((await del('/me/followers/bob', alice.token)).statusCode).toBe(200); + expect(names((await get('/users/alice/followers', alice.token)).json())).toEqual([]); + expect((await get('/feed', bob.token)).json()).toEqual([]); + }); +}); + +describe('blocking', () => { + async function blockedPair() { + const alice = await createUser(app(), 'alice'); + const bob = await createUser(app(), 'bob'); + await follow(app(), alice.token, 'bob'); + await follow(app(), bob.token, 'alice'); + await applyPollResult(alice.user.id, 'spotify', { track: { title: 'Secret', artist: 'X' }, isPlaying: true, progressMs: 0 }); + expect((await post('/users/bob/block', alice.token)).statusCode).toBe(200); + return { alice, bob }; + } + + it('severs follows in both directions', async () => { + const { alice, bob } = await blockedPair(); + expect(await db.select().from(schema.follows)).toHaveLength(0); + expect((await get('/feed', bob.token)).json()).toEqual([]); + expect((await get('/feed', alice.token)).json()).toEqual([]); + }); + + it('hides the blocker from the blocked person entirely', async () => { + const { bob } = await blockedPair(); + expect((await get('/users/alice', bob.token)).statusCode).toBe(404); + expect((await get('/users/alice/followers', bob.token)).statusCode).toBe(404); + expect((await get('/users/search?q=ali', bob.token)).json()).toEqual([]); + expect((await post('/users/alice/follow', bob.token)).statusCode).toBe(404); + }); + + it('still shows them to everyone else', async () => { + await blockedPair(); + const carol = await createUser(app(), 'carol'); + expect((await get('/users/alice', carol.token)).json().nowPlaying.track.title).toBe('Secret'); + }); + + it("the blocker sees a profile with activity hidden, and can unblock", async () => { + const { alice, bob } = await blockedPair(); + await applyPollResult(bob.user.id, 'spotify', { track: { title: 'Bobs song', artist: 'Y' }, isPlaying: true, progressMs: 0 }); + const profile = (await get('/users/bob', alice.token)).json(); + expect(profile).toMatchObject({ isBlocked: true, nowPlaying: null, recent: [], featured: null }); + expect((await post('/users/bob/follow', alice.token)).statusCode).toBe(400); + expect((await get('/me/blocks', alice.token)).json().map((u: { username: string }) => u.username)).toEqual(['bob']); + + await del('/users/bob/block', alice.token); + expect((await get('/users/alice', bob.token)).statusCode).toBe(200); + expect((await post('/users/bob/follow', alice.token)).statusCode).toBe(200); + }); + + it("can't block yourself", async () => { + const alice = await createUser(app(), 'alice'); + expect((await post('/users/alice/block', alice.token)).statusCode).toBe(400); + }); +}); + +describe('reporting', () => { + it('stores a report for moderation', async () => { + const alice = await createUser(app(), 'alice'); + await createUser(app(), 'spammer'); + const res = await post('/users/spammer/report', alice.token, { reason: 'spam', note: 'posting links' }); + expect(res.statusCode).toBe(200); + const [report] = await db.select().from(schema.reports); + expect(report).toMatchObject({ reason: 'spam', note: 'posting links', status: 'open', reporterId: alice.user.id }); + }); + + it('validates the reason and refuses self-reports', async () => { + const alice = await createUser(app(), 'alice'); + await createUser(app(), 'bob'); + expect((await post('/users/bob/report', alice.token, { reason: 'boring' })).statusCode).toBe(400); + expect((await post('/users/alice/report', alice.token, { reason: 'spam' })).statusCode).toBe(400); + }); +}); diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index 6f53592..68ca947 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/server.ts', 'src/worker.ts', 'src/db/migrate.ts'], + entry: ['src/server.ts', 'src/worker.ts', 'src/db/migrate.ts', 'src/scripts/set-role.ts'], format: ['esm'], target: 'node22', platform: 'node', diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index dad88e7..6634064 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -43,6 +43,8 @@ export default defineConfig({ // one shared database: run files one at a time fileParallelism: false, hookTimeout: 30_000, + // Password hashing is deliberately slow; tests that create many accounts need headroom on busy machines. + testTimeout: 20_000, }, }, ], diff --git a/apps/mobile/assets/expo.icon/Assets/expo-symbol 2.svg b/apps/mobile/assets/expo.icon/Assets/expo-symbol 2.svg deleted file mode 100644 index 51d3676..0000000 --- a/apps/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/mobile/assets/expo.icon/Assets/grid.png b/apps/mobile/assets/expo.icon/Assets/grid.png deleted file mode 100644 index eefea24..0000000 Binary files a/apps/mobile/assets/expo.icon/Assets/grid.png and /dev/null differ diff --git a/apps/mobile/assets/expo.icon/icon.json b/apps/mobile/assets/expo.icon/icon.json deleted file mode 100644 index 7a2c33c..0000000 --- a/apps/mobile/assets/expo.icon/icon.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "fill" : { - "automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000" - }, - "groups" : [ - { - "layers" : [ - { - "image-name" : "expo-symbol 2.svg", - "name" : "expo-symbol 2", - "position" : { - "scale" : 1, - "translation-in-points" : [ - 1.1008400065293245e-05, - -16.046875 - ] - } - }, - { - "image-name" : "grid.png", - "name" : "grid" - } - ], - "shadow" : { - "kind" : "neutral", - "opacity" : 0.5 - }, - "translucency" : { - "enabled" : true, - "value" : 0.5 - } - } - ], - "supported-platforms" : { - "circles" : [ - "watchOS" - ], - "squares" : "shared" - } -} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c94dac7..8c5f695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "typescript": "~6.0.3" }, "engines": { - "node": ">=22" + "node": ">=22.13" } }, "apps/api": { @@ -803,14 +803,19 @@ "name": "@musicshare/mobile", "version": "1.0.0", "dependencies": { + "@expo-google-fonts/space-grotesk": "^0.4.1", "@expo/ui": "~57.0.21", "@musicshare/shared": "*", "expo": "~57.0.26", + "expo-build-properties": "~57.0.22", + "expo-camera": "~57.0.6", "expo-constants": "~57.0.20", "expo-device": "~57.0.2", "expo-font": "~57.0.4", "expo-glass-effect": "~57.0.4", + "expo-haptics": "~57.0.3", "expo-image": "~57.0.5", + "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.11", "expo-router": "~57.0.24", "expo-secure-store": "~57.0.4", @@ -826,6 +831,7 @@ "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", "react-native-worklets": "0.10.1", "swr": "^2.5.1" @@ -844,6 +850,7 @@ "version": "0.0.0", "dependencies": { "@musicshare/shared": "*", + "motion": "^14.0.0", "next": "^16.3.8", "react": "19.2.3", "react-dom": "19.2.3", @@ -3169,6 +3176,12 @@ "integrity": "sha512-aZztj44PyA/sj3TXSwwpLJwqMlTUJ49okbZJYURzEcuERxMQB0KbX7RrLBi8WJnuWz5HFOLlTRxORrJfNnUPNQ==", "license": "MIT AND Apache-2.0" }, + "node_modules/@expo-google-fonts/space-grotesk": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/space-grotesk/-/space-grotesk-0.4.1.tgz", + "integrity": "sha512-ZVQYw4Ok/pgcSJiufP8oRZE3AVxS9xtmKEUfsurbHkHNdMc/GA1gDXP9G4Cr7KL4KqSc0haexR2TuMigotCn4Q==", + "license": "MIT AND OFL-1.1" + }, "node_modules/@expo/cli": { "version": "57.0.27", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.27.tgz", @@ -7368,6 +7381,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "1.41.6", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz", + "integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -8587,6 +8606,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/barcode-detector": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.2.tgz", + "integrity": "sha512-/4QOrrNrCRmDSBWiiP4aC72dnkuXUEdcFRidHgbPRXUpy82XcCMvJssI6fEs6JT42LS7DvBVZO70qasJbYKyrA==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.1.3" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8628,6 +8656,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -9313,6 +9347,47 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -9534,6 +9609,61 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "18.0.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.5.tgz", @@ -9758,6 +9888,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -10904,6 +11046,40 @@ "react-native": "*" } }, + "node_modules/expo-build-properties": { + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.22.tgz", + "integrity": "sha512-A5oRf2I02XDRu2NnqKo7Au5kUu244unvyoRMUsB+xvg/BOlsR+G/oyDFGkjbda1cJbeKm9eqposIXbA2ri5UrQ==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^57.0.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-camera": { + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.6.tgz", + "integrity": "sha512-eswB77bOzQ2OEvll/hV5VnzywaICFnYF21HIwcyokebFcSTSMfGQk8+VW06DRec9JnWuRExs0QXlfxyMPjS6KQ==", + "license": "MIT", + "dependencies": { + "barcode-detector": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "57.0.20", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.20.tgz", @@ -10964,6 +11140,15 @@ "react-native": "*" } }, + "node_modules/expo-haptics": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.3.tgz", + "integrity": "sha512-qucUXtwtJdtECKSQK5D6TQBc/0SxKj7s5wQ4mrfSxhZsHrS9a4rcxINl/gbruMaY9spTXStco32ST4pV0vXmxw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image": { "version": "57.0.5", "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.5.tgz", @@ -10994,6 +11179,17 @@ "react": "*" } }, + "node_modules/expo-linear-gradient": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-57.0.2.tgz", + "integrity": "sha512-eGNSfcbdXYvw0B/TQ3nmXKQgj0jLUi3EgsXGlvI79rlXg5RR5dQTkY2yqkf7BQDM3PMd3tqBMZdsLhvBqSUaMg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-linking": { "version": "57.0.11", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.11.tgz", @@ -11664,6 +11860,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.0.0.tgz", + "integrity": "sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==", + "license": "MIT", + "dependencies": { + "motion-dom": "14.0.0", + "motion-utils": "14.0.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -13634,6 +13853,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -14780,6 +15005,43 @@ "ufo": "^1.6.3" } }, + "node_modules/motion": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-14.0.0.tgz", + "integrity": "sha512-oGc2lkge2lkRZCP+J8/JsnYu4NFjqP1MtXirNdetn3QbvK2JqwM9A200EdT4C598KfzDhjLT12zF21xDM4Oilw==", + "license": "MIT", + "dependencies": { + "framer-motion": "14.0.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.0.0.tgz", + "integrity": "sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==", + "license": "MIT", + "dependencies": { + "motion-utils": "14.0.0" + } + }, + "node_modules/motion-utils": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.0.0.tgz", + "integrity": "sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -15047,6 +15309,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -15873,6 +16147,12 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -16122,6 +16402,21 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.15.4", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz", + "integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.3.tgz", @@ -17760,6 +18055,18 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -19869,10 +20176,39 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zxing-wasm": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.3.tgz", + "integrity": "sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.8.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/shared": { "name": "@musicshare/shared", "version": "0.0.0", "dependencies": { + "qrcode-generator": "^2.0.4", "zod": "^4.6.5" }, "devDependencies": { diff --git a/packages/shared/package.json b/packages/shared/package.json index e74f437..f978190 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,7 @@ "test:watch": "vitest" }, "dependencies": { + "qrcode-generator": "^2.0.4", "zod": "^4.6.5" }, "devDependencies": { diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index 1d1e12f..077a0bd 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -75,7 +75,7 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt searchTracks: (query: string) => request(`/tracks/search?q=${q(query)}`), connections: () => request('/connections'), - authorizeUrl: (provider: 'spotify' | 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) => + authorizeUrl: (provider: 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) => request<{ url: string }>(`/connections/${provider}/authorize`, { body }), connectUsername: (provider: 'lastfm' | 'listenbrainz', username: string) => request(`/connections/${provider}`, { body: { username } }), diff --git a/packages/shared/src/dto.ts b/packages/shared/src/dto.ts index 56c1fa2..ac95a31 100644 --- a/packages/shared/src/dto.ts +++ b/packages/shared/src/dto.ts @@ -23,6 +23,9 @@ export interface MeDTO extends UserSummaryDTO { email: string; bio: string | null; preferredService: StreamingService | null; + role: 'user' | 'admin'; + /** When onboarding was finished; null means show it. */ + onboardedAt: string | null; /** ISO time sharing resumes, or null when sharing. Far future means "until turned back on". */ sharingPausedUntil: string | null; plan: Plan; @@ -77,6 +80,10 @@ export interface ProfileDTO { isFollowing: boolean; isPinned: boolean; isSelf: boolean; + /** They follow the viewer. */ + followsYou: boolean; + /** The viewer has blocked them (activity is hidden; they can be unblocked). */ + isBlocked: boolean; featured: FeaturedSongDTO | null; nowPlaying: NowPlayingDTO | null; /** Most recent first, not including the track playing right now. */ @@ -85,6 +92,35 @@ export interface ProfileDTO { attribution: Partial>; } +export interface AdminReportDTO { + id: string; + reason: string; + note: string | null; + status: 'open' | 'resolved'; + createdAt: string; + reporter: UserSummaryDTO | null; + reported: UserSummaryDTO & { suspended: boolean }; +} + +export interface AdminStatsDTO { + users: number; + listeningNow: number; + openReports: number; + suspended: number; +} + +/** A row in a followers/following list. */ +export interface FollowListItemDTO extends UserSummaryDTO { + /** Whether the viewer follows this person (for Follow/Following buttons). */ + isFollowing: boolean; +} + +export interface FollowListDTO { + users: FollowListItemDTO[]; + /** Pass back as ?cursor= for the next page; null when there are no more. */ + nextCursor: string | null; +} + export interface ConnectionDTO { provider: NowPlayingProvider; externalUserId: string; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dfaacb4..7c02f8e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,3 +5,5 @@ export * from './plans'; export * from './client'; export * from './time'; export * from './devices'; +export * from './links'; +export * from './qr'; diff --git a/packages/shared/src/links.test.ts b/packages/shared/src/links.test.ts new file mode 100644 index 0000000..225a761 --- /dev/null +++ b/packages/shared/src/links.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { parseProfileLink, profileUrl } from './links'; + +describe('profileUrl', () => { + it('builds a website profile link, tolerating a trailing slash', () => { + expect(profileUrl('https://musicshare.app/', 'alice')).toBe('https://musicshare.app/u/alice'); + }); +}); + +describe('parseProfileLink', () => { + it.each([ + ['https://musicshare.app/u/alice', 'alice'], + ['http://192.168.1.215:3737/u/Bob_99', 'bob_99'], + ['https://musicshare.app/u/alice/', 'alice'], + ['https://musicshare.app/u/alice?ref=qr', 'alice'], + ['musicshare://u/alice', 'alice'], + ['exp://192.168.1.20:8081/--/u/alice', 'alice'], + ['@alice', 'alice'], + [' https://musicshare.app/u/alice ', 'alice'], + ])('reads %s', (text, expected) => { + expect(parseProfileLink(text)).toBe(expected); + }); + + it.each([ + 'https://example.com/', + 'https://musicshare.app/u/', + 'https://musicshare.app/u/alice/extra', + 'https://musicshare.app/u/a', // too short to be a username + 'https://musicshare.app/u/al%20ice', + 'WIFI:S:home;T:WPA;P:secret;;', + 'just some text', + '@x', + ])('rejects %s', (text) => { + expect(parseProfileLink(text)).toBeNull(); + }); +}); diff --git a/packages/shared/src/links.ts b/packages/shared/src/links.ts new file mode 100644 index 0000000..08d515d --- /dev/null +++ b/packages/shared/src/links.ts @@ -0,0 +1,35 @@ +/** Profile links: what QR codes and invites contain, and how scans are read back. */ + +const USERNAME = /^[a-z0-9_]{3,32}$/; + +/** Shareable profile URL on the website, e.g. https://musicshare.app/u/alice */ +export function profileUrl(webBase: string, username: string): string { + return `${webBase.replace(/\/+$/, '')}/u/${encodeURIComponent(username)}`; +} + +/** + * Pull a username out of anything a QR scan might contain: a website profile + * URL (any host), the app's deep link (musicshare://u/alice), an Expo Go dev + * link (exp://…/--/u/alice), or a bare "@alice". Returns null if it isn't one. + */ +export function parseProfileLink(text: string): string | null { + const raw = text.trim(); + const at = raw.match(/^@([A-Za-z0-9_]+)$/); + if (at) return valid(at[1]!); + + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + // musicshare://u/alice parses with host "u" and path "/alice" + const path = url.protocol === 'musicshare:' ? `/${url.host}${url.pathname}` : url.pathname.replace(/^\/--/, ''); + const match = path.match(/^\/u\/([^/?#]+)\/?$/); + return match ? valid(decodeURIComponent(match[1]!)) : null; +} + +function valid(name: string): string | null { + const username = name.toLowerCase(); + return USERNAME.test(username) ? username : null; +} diff --git a/packages/shared/src/qr.test.ts b/packages/shared/src/qr.test.ts new file mode 100644 index 0000000..0b4fc4c --- /dev/null +++ b/packages/shared/src/qr.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { qrMatrix, qrShape } from './qr'; + +describe('qr', () => { + const url = 'https://musicshare.app/u/alice'; + + it('produces a square grid with the three finder patterns', () => { + const m = qrMatrix(url); + const n = m.length; + expect(n).toBeGreaterThanOrEqual(21); + expect(m.every((row) => row.length === n)).toBe(true); + // Finder pattern corners: dark outer ring, light inner ring, dark 3x3 centre. + for (const [r, c] of [ + [0, 0], + [0, n - 7], + [n - 7, 0], + ] as const) { + expect(m[r]![c]).toBe(true); + expect(m[r + 1]![c + 1]).toBe(false); + expect(m[r + 3]![c + 3]).toBe(true); + } + }); + + it('is deterministic and draws every dark module', () => { + const a = qrShape(url); + expect(qrShape(url)).toEqual(a); + const darkCount = qrMatrix(url).flat().filter(Boolean).length; + const drawn = [...a.path.matchAll(/h(\d+)v1/g)].reduce((sum, m) => sum + Number(m[1]), 0); + expect(drawn).toBe(darkCount); + }); + + it('grows for longer text', () => { + expect(qrShape(`${url}?${'x'.repeat(200)}`).size).toBeGreaterThan(qrShape(url).size); + }); +}); diff --git a/packages/shared/src/qr.ts b/packages/shared/src/qr.ts new file mode 100644 index 0000000..398f55c --- /dev/null +++ b/packages/shared/src/qr.ts @@ -0,0 +1,36 @@ +import qrcode from 'qrcode-generator'; + +export interface QrShape { + /** Width/height in modules (no quiet zone; renderers add margin). */ + size: number; + /** SVG path in module units: draw it in a `0 0 size size` viewBox. */ + path: string; +} + +/** Dark/light grid for `text`. Medium error correction survives a scratched or dim screen. */ +export function qrMatrix(text: string): boolean[][] { + const qr = qrcode(0, 'M'); // 0 = smallest version that fits + qr.addData(text); + qr.make(); + const n = qr.getModuleCount(); + return Array.from({ length: n }, (_, row) => Array.from({ length: n }, (_, col) => qr.isDark(row, col))); +} + +/** One SVG path for the whole code, merging horizontal runs so it stays small to render. */ +export function qrShape(text: string): QrShape { + const matrix = qrMatrix(text); + let path = ''; + matrix.forEach((cells, y) => { + let x = 0; + while (x < cells.length) { + if (!cells[x]) { + x++; + continue; + } + const start = x; + while (x < cells.length && cells[x]) x++; + path += `M${start} ${y}h${x - start}v1h${start - x}z`; + } + }); + return { size: matrix.length, path }; +} diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 6a67415..f23cbd9 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -26,6 +26,8 @@ export const updateMeSchema = z.object({ 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(), }); export const setFeaturedSchema = z.union([ @@ -51,3 +53,10 @@ export const jellyfinConnectSchema = z.object({ /** Pause sharing: for an hour, until tomorrow, until turned back on, or resume (null). */ export const PAUSE_OPTIONS = ['1h', '8h', 'indefinitely'] as const; export const setSharingSchema = z.object({ pause: z.enum(PAUSE_OPTIONS).nullable() }); + +export const REPORT_REASONS = ['spam', 'harassment', 'inappropriate', 'impersonation', 'other'] as const; +export type ReportReason = (typeof REPORT_REASONS)[number]; +export const reportSchema = z.object({ + reason: z.enum(REPORT_REASONS), + note: z.string().trim().max(500).nullish(), +});