diff --git a/apps/api/drizzle/0016_notifications.sql b/apps/api/drizzle/0016_notifications.sql new file mode 100644 index 0000000..c799f26 --- /dev/null +++ b/apps/api/drizzle/0016_notifications.sql @@ -0,0 +1,38 @@ +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "type" text NOT NULL, + "actor_id" uuid NOT NULL, + "track_id" uuid, + "listen_id" uuid, + "emoji" varchar(32), + "message" varchar(280), + "read_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "push_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "kind" text NOT NULL, + "endpoint" text NOT NULL, + "p256dh" text NOT NULL, + "auth" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint") +); +--> statement-breakpoint +CREATE TABLE "server_keys" ( + "name" text PRIMARY KEY NOT NULL, + "value" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "notify_prefs" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_listen_id_listens_id_fk" FOREIGN KEY ("listen_id") REFERENCES "public"."listens"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "notifications_user_created_idx" ON "notifications" USING btree ("user_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "push_subscriptions_user_idx" ON "push_subscriptions" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0016_snapshot.json b/apps/api/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..c1c3d1e --- /dev/null +++ b/apps/api/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1839 @@ +{ + "id": "08aa0049-d19c-4d78-be82-c577de3d211c", + "prevId": "f7a38758-1668-4e86-bb01-e20b2d1e36e0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.avatars": { + "name": "avatars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "avatars_user_id_users_id_fk": { + "name": "avatars_user_id_users_id_fk", + "tableFrom": "avatars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_id": { + "name": "blocked_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocks_blocked_idx": { + "name": "blocks_blocked_idx", + "columns": [ + { + "expression": "blocked_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocks_blocker_id_users_id_fk": { + "name": "blocks_blocker_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocks_blocked_id_users_id_fk": { + "name": "blocks_blocked_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocked_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocks_blocker_id_blocked_id_pk": { + "name": "blocks_blocker_id_blocked_id_pk", + "columns": [ + "blocker_id", + "blocked_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connections_user_provider_idx": { + "name": "connections_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_user_id_users_id_fk": { + "name": "connections_user_id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "connections_webhook_secret_unique": { + "name": "connections_webhook_secret_unique", + "nullsNotDistinct": false, + "columns": [ + "webhook_secret" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_idx": { + "name": "devices_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_token_hash_unique": { + "name": "devices_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verifications": { + "name": "email_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "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": { + "email_verifications_user_idx": { + "name": "email_verifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_verifications_user_id_users_id_fk": { + "name": "email_verifications_user_id_users_id_fk", + "tableFrom": "email_verifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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.lyrics": { + "name": "lyrics", + "schema": "", + "columns": { + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "synced": { + "name": "synced", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plain": { + "name": "plain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instrumental": { + "name": "instrumental", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "lyrics_track_id_tracks_id_fk": { + "name": "lyrics_track_id_tracks_id_fk", + "tableFrom": "lyrics", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "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 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "listen_id": { + "name": "listen_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emoji": { + "name": "emoji", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "varchar(280)", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_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": { + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_actor_id_users_id_fk": { + "name": "notifications_actor_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_track_id_tracks_id_fk": { + "name": "notifications_track_id_tracks_id_fk", + "tableFrom": "notifications", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_listen_id_listens_id_fk": { + "name": "notifications_listen_id_listens_id_fk", + "tableFrom": "notifications", + "tableTo": "listens", + "columnsFrom": [ + "listen_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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.password_resets": { + "name": "password_resets", + "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 + }, + "used_at": { + "name": "used_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": { + "password_resets_user_idx": { + "name": "password_resets_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_resets_user_id_users_id_fk": { + "name": "password_resets_user_id_users_id_fk", + "tableFrom": "password_resets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_subscriptions_user_idx": { + "name": "push_subscriptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "listen_id": { + "name": "listen_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reactions_listen_id_listens_id_fk": { + "name": "reactions_listen_id_listens_id_fk", + "tableFrom": "reactions", + "tableTo": "listens", + "columnsFrom": [ + "listen_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reactions_listen_id_user_id_pk": { + "name": "reactions_listen_id_user_id_pk", + "columns": [ + "listen_id", + "user_id" + ] + } + }, + "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.server_keys": { + "name": "server_keys", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "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 + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pending_email": { + "name": "pending_email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "services": { + "name": "services", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "notify_prefs": { + "name": "notify_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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 48e3730..da45b76 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1792025274882, "tag": "0015_lyrics", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1792028149643, + "tag": "0016_notifications", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 45aed29..d77119d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -36,11 +36,13 @@ "postgres": "^3.4.9", "sharp": "^0.35.5", "undici": "^6.29.0", + "web-push": "^3.6.7", "zod": "^4.6.5" }, "devDependencies": { "@types/node": "^26.6.4", "@types/nodemailer": "^8.0.2", + "@types/web-push": "^3.6.4", "@types/ws": "^8.18.2", "concurrently": "^10.0.5", "drizzle-kit": "^0.31.11", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 30bf0b8..a3a58ce 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -18,6 +18,7 @@ import { connectionRoutes } from './routes/connections'; import { deviceRoutes } from './routes/devices'; import { meRoutes } from './routes/me'; import { reactionRoutes } from './routes/reactions'; +import { notificationRoutes } from './routes/notifications'; import { realtimeRoutes } from './routes/realtime'; import { safetyRoutes } from './routes/safety'; import { trackRoutes } from './routes/tracks'; @@ -76,6 +77,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?: await app.register(adminRoutes); await app.register(trackRoutes); await app.register(reactionRoutes); + await app.register(notificationRoutes); await app.register(connectionRoutes); await app.register(deviceRoutes); await app.register(realtimeRoutes); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 4b3749c..4ca7e97 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -13,7 +13,7 @@ import { uuid, varchar, } from 'drizzle-orm/pg-core'; -import type { NowPlayingProvider, Plan, ReportReason, StreamingService } from '@musicshare/shared'; +import type { NotificationPrefs, NotificationType, NowPlayingProvider, Plan, ReportReason, StreamingService } from '@musicshare/shared'; const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow(); @@ -33,6 +33,8 @@ export const users = pgTable('users', { preferredService: text('preferred_service').$type(), /** The services they use, so songs only offer those; null until they choose (offer all). */ services: text('services').array().$type(), + /** Which notifications to push; missing keys mean on. Everything still shows in the app's list. */ + notifyPrefs: jsonb('notify_prefs').$type>().notNull().default({}), /** 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'), @@ -304,6 +306,54 @@ export const lyrics = pgTable('lyrics', { fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(), }); +/** Things that happened to a user: a reaction, a new follower, a song sent to them. */ +export const notifications = pgTable( + 'notifications', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + type: text('type').$type().notNull(), + actorId: uuid('actor_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + trackId: uuid('track_id').references(() => tracks.id, { onDelete: 'cascade' }), + /** The play a reaction was on (gone once the play is trimmed from history). */ + listenId: uuid('listen_id').references(() => listens.id, { onDelete: 'set null' }), + emoji: varchar('emoji', { length: 32 }), + /** A note sent with a song. */ + message: varchar('message', { length: 280 }), + readAt: timestamp('read_at', { withTimezone: true }), + createdAt: createdAt(), + }, + (t) => [index('notifications_user_created_idx').on(t.userId, t.createdAt.desc())], +); + +/** Where to push notifications: browsers (Web Push) and phones (UnifiedPush). Both use Web Push encryption. */ +export const pushSubscriptions = pgTable( + 'push_subscriptions', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + kind: text('kind').$type<'web' | 'unifiedpush'>().notNull(), + endpoint: text('endpoint').notNull().unique(), + p256dh: text('p256dh').notNull(), + auth: text('auth').notNull(), + createdAt: createdAt(), + }, + (t) => [index('push_subscriptions_user_idx').on(t.userId)], +); + +/** Keys the server generates for itself once (e.g. the Web Push VAPID key pair). */ +export const serverKeys = pgTable('server_keys', { + name: text('name').primaryKey(), + value: jsonb('value').$type>().notNull(), + createdAt: createdAt(), +}); + const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' }); /** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */ diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index af2d468..f12771c 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -26,6 +26,9 @@ const schema = z.object({ /** Enables "Sign in with Last.fm" (web auth). Without it, users type their username. */ LASTFM_SHARED_SECRET: optional, ODESLI_API_KEY: optional, + /** Web Push keys (`npx web-push generate-vapid-keys`). Optional: without them the server makes a pair and keeps it in the database. */ + VAPID_PUBLIC_KEY: optional, + VAPID_PRIVATE_KEY: optional, /** Lyrics come from LRCLIB (open source, self-hostable). Point this at your own instance, or set it empty to turn lyrics off. */ LRCLIB_URL: z .string() diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 72c234e..a5310ff 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -1,6 +1,6 @@ import { eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; -import { setFeaturedSchema, setSharingSchema, updateMeSchema, type MeDTO } from '@musicshare/shared'; +import { notificationPrefs, setFeaturedSchema, setSharingSchema, updateMeSchema, type MeDTO } from '@musicshare/shared'; import { db, schema } from '../db'; import type { User } from '../db/schema'; import { SESSION_COOKIE, requireUser } from '../lib/auth'; @@ -25,6 +25,7 @@ export const toMeDTO = (u: User): MeDTO => ({ bio: u.bio, preferredService: u.preferredService, services: u.services, + notifyPrefs: notificationPrefs(u.notifyPrefs), role: u.role, onboardedAt: u.onboardedAt?.toISOString() ?? null, sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null, @@ -40,7 +41,12 @@ export async function meRoutes(app: FastifyInstance) { const { onboarded, ...input } = updateMeSchema.parse(req.body); // Clearing the display name falls back to the username. if ('displayName' in input && !input.displayName) input.displayName = user.username; - const changes = { ...input, ...(onboarded && !user.onboardedAt ? { onboardedAt: new Date() } : {}) }; + const { notifyPrefs, ...rest } = input; + const changes = { + ...rest, + ...(notifyPrefs ? { notifyPrefs: { ...user.notifyPrefs, ...notifyPrefs } } : {}), + ...(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/notifications.ts b/apps/api/src/routes/notifications.ts new file mode 100644 index 0000000..c459fed --- /dev/null +++ b/apps/api/src/routes/notifications.ts @@ -0,0 +1,88 @@ +import { and, eq, inArray, isNull } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { pushSubscriptionSchema, sendSongSchema } from '@musicshare/shared'; +import { db, schema } from '../db'; +import { requireUser } from '../lib/auth'; +import { HttpError } from '../lib/http'; +import { UnsafeUrlError, assertSafeServerUrl } from '../lib/ssrf'; +import { hiddenUserIds } from '../services/blocks'; +import { listNotifications, notify, vapidKeys } from '../services/notifications'; +import { enqueueResolve } from '../services/queues'; + +/** Most browsers and phones someone might have at once. */ +const MAX_PUSH_SUBSCRIPTIONS = 10; + +export async function notificationRoutes(app: FastifyInstance) { + app.get<{ Querystring: { cursor?: string } }>('/notifications', async (req) => { + const user = requireUser(req); + return listNotifications(user.id, req.query.cursor ?? null); + }); + + app.post('/notifications/read', async (req) => { + const user = requireUser(req); + const n = schema.notifications; + await db.update(n).set({ readAt: new Date() }).where(and(eq(n.userId, user.id), isNull(n.readAt))); + return { ok: true }; + }); + + /** Send a song to people who follow you (and haven't blocked you). Others are skipped. */ + app.post('/shares', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (req) => { + const user = requireUser(req); + const { trackId, to, message } = sendSongSchema.parse(req.body); + const [track] = await db.select({ id: schema.tracks.id }).from(schema.tracks).where(eq(schema.tracks.id, trackId)); + if (!track) throw new HttpError(404, 'Track not found'); + + const recipients = await db + .select({ id: schema.users.id, username: schema.users.username }) + .from(schema.users) + .innerJoin(schema.follows, and(eq(schema.follows.followerId, schema.users.id), eq(schema.follows.followeeId, user.id))) + .where(and(inArray(schema.users.username, to), isNull(schema.users.suspendedAt))); + const hidden = await hiddenUserIds(user.id); + const sentTo: string[] = []; + for (const r of recipients) { + if (hidden.has(r.id)) continue; + await notify({ userId: r.id, actorId: user.id, type: 'share', trackId, message: message || null }); + sentTo.push(r.username); + } + if (sentTo.length === 0) throw new HttpError(400, 'You can only send songs to people who follow you'); + await enqueueResolve(trackId); // so it opens straight in their service + return { sentTo }; + }); + + /** The server's public key, for subscribing browsers and phones to push. */ + app.get('/push/key', async () => ({ publicKey: (await vapidKeys()).publicKey })); + + app.put('/me/push', async (req) => { + const user = requireUser(req); + const input = pushSubscriptionSchema.parse(req.body); + try { + await assertSafeServerUrl(input.endpoint); + } catch (err) { + if (err instanceof UnsafeUrlError) throw new HttpError(400, `Can't send notifications there: ${err.message}`); + throw err; + } + const values = { userId: user.id, kind: input.kind, endpoint: input.endpoint, ...input.keys }; + await db + .insert(schema.pushSubscriptions) + .values(values) + .onConflictDoUpdate({ target: schema.pushSubscriptions.endpoint, set: values }); + // Keep the newest few. + const subs = await db + .select({ id: schema.pushSubscriptions.id, createdAt: schema.pushSubscriptions.createdAt }) + .from(schema.pushSubscriptions) + .where(eq(schema.pushSubscriptions.userId, user.id)); + const extra = subs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).slice(MAX_PUSH_SUBSCRIPTIONS); + if (extra.length) await db.delete(schema.pushSubscriptions).where(inArray(schema.pushSubscriptions.id, extra.map((s) => s.id))); + return { ok: true }; + }); + + app.delete('/me/push', async (req) => { + const user = requireUser(req); + const { endpoint } = z.object({ endpoint: z.string().max(2000) }).parse(req.body); + await db + .delete(schema.pushSubscriptions) + .where(and(eq(schema.pushSubscriptions.userId, user.id), eq(schema.pushSubscriptions.endpoint, endpoint))); + return { ok: true }; + }); +} diff --git a/apps/api/src/routes/reactions.ts b/apps/api/src/routes/reactions.ts index 2b997a4..14cb575 100644 --- a/apps/api/src/routes/reactions.ts +++ b/apps/api/src/routes/reactions.ts @@ -6,6 +6,7 @@ import { db, schema } from '../db'; import { requireUser } from '../lib/auth'; import { HttpError } from '../lib/http'; import { blockStatus } from '../services/blocks'; +import { notify, unnotifyReaction } from '../services/notifications'; import { getReactions, publishNowPlaying } from '../services/nowPlaying'; const params = z.object({ id: z.uuid() }); @@ -42,6 +43,7 @@ export async function reactionRoutes(app: FastifyInstance) { .insert(schema.reactions) .values({ listenId: id, userId: user.id, emoji }) .onConflictDoUpdate({ target: [schema.reactions.listenId, schema.reactions.userId], set: { emoji, createdAt: new Date() } }); + await notify({ userId: listen.userId, actorId: user.id, type: 'reaction', listenId: id, trackId: listen.trackId, emoji }); return afterChange(listen); }); @@ -50,6 +52,7 @@ export async function reactionRoutes(app: FastifyInstance) { const { id } = params.parse(req.params); const listen = await reactable(user.id, id); await db.delete(schema.reactions).where(and(eq(schema.reactions.listenId, id), eq(schema.reactions.userId, user.id))); + await unnotifyReaction(listen.userId, user.id, id); return afterChange(listen); }); } diff --git a/apps/api/src/routes/realtime.ts b/apps/api/src/routes/realtime.ts index 5902d37..a26a0ba 100644 --- a/apps/api/src/routes/realtime.ts +++ b/apps/api/src/routes/realtime.ts @@ -12,6 +12,7 @@ import type { WebSocket } from 'ws'; import { db, schema } from '../db'; import { FOLLOWS_CHANGED_CHANNEL, createRedis } from '../lib/redis'; import { watchUsers } from '../services/connections'; +import { unreadCount } from '../services/notifications'; const watchers = new Map>(); // watched userId -> sockets const socketsByOwner = new Map>(); // viewer userId -> their sockets @@ -42,10 +43,23 @@ async function subscribeSocket(socket: WebSocket, viewerId: string) { export async function realtimeRoutes(app: FastifyInstance) { const sub = createRedis(); - await sub.psubscribe('np:*'); + await sub.psubscribe('np:*', 'notify:*'); await sub.subscribe(FOLLOWS_CHANGED_CHANNEL); - sub.on('pmessage', (_pattern, channel, message) => { + sub.on('pmessage', (pattern, channel, message) => { + if (pattern === 'notify:*') { + // Something happened to this user: tell their open apps the new unread count. + const userId = channel.slice('notify:'.length); + const sockets = socketsByOwner.get(userId); + if (!sockets) return; + unreadCount(userId) + .then((unread) => { + const payload = JSON.stringify({ type: 'notification', data: { unread } }); + for (const socket of sockets) socket.send(payload); + }) + .catch((e) => app.log.error(e)); + return; + } const userId = channel.slice(3); const payload = `{"type":"now_playing","data":${message}}`; for (const socket of watchers.get(userId) ?? []) socket.send(payload); diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index 8bebb62..0cda2ad 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -2,6 +2,7 @@ import { and, count, desc, eq, ilike, inArray, isNotNull, isNull, or } from 'dri import type { FastifyInstance } from 'fastify'; import { sortFeed, sourceProfileUrl, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared'; import { db, schema } from '../db'; +import { notify } from '../services/notifications'; import { requireUser } from '../lib/auth'; import { entitlementsFor } from '../lib/entitlements'; import { HttpError } from '../lib/http'; @@ -107,8 +108,13 @@ export async function userRoutes(app: FastifyInstance) { 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(); + const added = await db + .insert(schema.follows) + .values({ followerId: viewer.id, followeeId: target.id }) + .onConflictDoNothing() + .returning({ id: schema.follows.followerId }); await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id); + if (added.length) await notify({ userId: target.id, actorId: viewer.id, type: 'follow' }); return { ok: true }; }); diff --git a/apps/api/src/services/notifications.ts b/apps/api/src/services/notifications.ts new file mode 100644 index 0000000..6db59c9 --- /dev/null +++ b/apps/api/src/services/notifications.ts @@ -0,0 +1,194 @@ +import { and, desc, eq, gt, inArray, isNull, lt } from 'drizzle-orm'; +import webpush from 'web-push'; +import { + notificationPath, + notificationPrefs, + notificationText, + type NotificationDTO, + type NotificationType, +} from '@musicshare/shared'; +import { db, schema } from '../db'; +import { env } from '../env'; +import { redis } from '../lib/redis'; +import { assertSafeServerUrl, serverFetchOptions } from '../lib/ssrf'; +import { toUserSummary } from './nowPlaying'; +import { enqueuePush } from './queues'; +import { getTrackDTOs } from './tracks'; + +export const notifyChannel = (userId: string) => `notify:${userId}`; + +/** A second follow from the same person within this long (unfollow, follow again) isn't news. */ +const FOLLOW_REPEAT_MS = 24 * 3600_000; + +type Row = typeof schema.notifications.$inferSelect; + +/** + * Record something that happened to `userId` and push it. Reactions replace that + * person's earlier reaction to the same play rather than piling up. + */ +export async function notify(input: { + userId: string; + actorId: string; + type: NotificationType; + trackId?: string | null; + listenId?: string | null; + emoji?: string | null; + message?: string | null; +}): Promise { + if (input.userId === input.actorId) return null; + const n = schema.notifications; + const mine = and(eq(n.userId, input.userId), eq(n.actorId, input.actorId), eq(n.type, input.type)); + + let row: Row | undefined; + if (input.type === 'follow') { + const [recent] = await db + .select({ id: n.id }) + .from(n) + .where(and(mine, gt(n.createdAt, new Date(Date.now() - FOLLOW_REPEAT_MS)))); + if (recent) return null; + } + if (input.type === 'reaction' && input.listenId) { + [row] = await db + .update(n) + .set({ emoji: input.emoji, createdAt: new Date(), readAt: null }) + .where(and(mine, eq(n.listenId, input.listenId))) + .returning(); + } + if (!row) [row] = await db.insert(n).values(input).returning(); + + await redis.publish(notifyChannel(input.userId), row!.id); + await enqueuePush(row!.id); + return row!.id; +} + +/** Take back a reaction's notification when the reaction is removed (if it hasn't been seen). */ +export async function unnotifyReaction(userId: string, actorId: string, listenId: string) { + const n = schema.notifications; + await db + .delete(n) + .where(and(eq(n.userId, userId), eq(n.actorId, actorId), eq(n.type, 'reaction'), eq(n.listenId, listenId), isNull(n.readAt))); +} + +export async function toNotificationDTOs(rows: Row[]): Promise { + const actorIds = [...new Set(rows.map((r) => r.actorId))]; + const actors = actorIds.length + ? await db.select().from(schema.users).where(inArray(schema.users.id, actorIds)) + : []; + const byId = new Map(actors.map((u) => [u.id, toUserSummary(u)])); + const tracks = await getTrackDTOs([...new Set(rows.flatMap((r) => (r.trackId ? [r.trackId] : [])))]); + return rows.flatMap((r) => { + const actor = byId.get(r.actorId); + if (!actor) return []; + return [ + { + id: r.id, + type: r.type, + actor, + track: r.trackId ? (tracks.get(r.trackId) ?? null) : null, + emoji: r.emoji, + message: r.message, + read: Boolean(r.readAt), + createdAt: r.createdAt.toISOString(), + }, + ]; + }); +} + +/** Newest first, 30 at a time; the cursor is the last one's time. */ +export async function listNotifications(userId: string, cursor: string | null) { + const n = schema.notifications; + const before = cursor ? new Date(cursor) : null; + const rows = await db + .select() + .from(n) + .where(and(eq(n.userId, userId), before && !isNaN(before.getTime()) ? lt(n.createdAt, before) : undefined)) + .orderBy(desc(n.createdAt)) + .limit(31); + const page = rows.slice(0, 30); + return { + notifications: await toNotificationDTOs(page), + unread: await unreadCount(userId), + nextCursor: rows.length > 30 ? page.at(-1)!.createdAt.toISOString() : null, + }; +} + +export async function unreadCount(userId: string): Promise { + const n = schema.notifications; + const rows = await db + .select({ id: n.id }) + .from(n) + .where(and(eq(n.userId, userId), isNull(n.readAt))) + .limit(100); + return rows.length; +} + +// --- Push ------------------------------------------------------------------------------------- + +let vapid: { publicKey: string; privateKey: string } | null = null; + +/** + * The VAPID key pair push services use to know messages come from us. Set + * VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY, or one is made on first use and kept in + * the database (changing it would break every existing subscription). + */ +export async function vapidKeys() { + if (vapid) return vapid; + if (env.VAPID_PUBLIC_KEY && env.VAPID_PRIVATE_KEY) return (vapid = { publicKey: env.VAPID_PUBLIC_KEY, privateKey: env.VAPID_PRIVATE_KEY }); + const generated = webpush.generateVAPIDKeys(); + await db.insert(schema.serverKeys).values({ name: 'vapid', value: { ...generated } }).onConflictDoNothing(); + const [row] = await db.select().from(schema.serverKeys).where(eq(schema.serverKeys.name, 'vapid')); + return (vapid = row!.value as { publicKey: string; privateKey: string }); +} + +/** Send one notification to all of its user's browsers and phones, if they want this kind. */ +export async function sendPush(notificationId: string) { + const [row] = await db.select().from(schema.notifications).where(eq(schema.notifications.id, notificationId)); + if (!row || row.readAt) return; + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, row.userId)); + if (!user || !notificationPrefs(user.notifyPrefs)[row.type]) return; + const subs = await db.select().from(schema.pushSubscriptions).where(eq(schema.pushSubscriptions.userId, row.userId)); + if (subs.length === 0) return; + const [dto] = await toNotificationDTOs([row]); + if (!dto) return; + + const { title, body } = notificationText(dto); + const payload = JSON.stringify({ + id: dto.id, + type: dto.type, + title, + body, + path: notificationPath(dto), + icon: dto.actor.avatarUrl, + unread: await unreadCount(row.userId), + }); + const keys = await vapidKeys(); + await Promise.all( + subs.map(async (sub) => { + try { + // web-push encrypts and signs; we send it ourselves so the address is checked like any user-supplied URL. + const req = webpush.generateRequestDetails({ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, payload, { + vapidDetails: { subject: env.WEB_URL.startsWith('https:') ? env.WEB_URL : 'mailto:app@musicshare.binarygnome.com', ...keys }, + TTL: 24 * 3600, + urgency: 'normal', + contentEncoding: 'aes128gcm', + }); + await assertSafeServerUrl(req.endpoint); + const res = await fetch(req.endpoint, { + method: req.method, + headers: req.headers as Record, + body: req.body as Buffer, + signal: AbortSignal.timeout(10_000), + ...serverFetchOptions(), + }); + // Gone: the browser unsubscribed or the app was uninstalled. + if (res.status === 404 || res.status === 410) { + await db.delete(schema.pushSubscriptions).where(eq(schema.pushSubscriptions.id, sub.id)); + } else if (!res.ok) { + console.warn(`push to ${new URL(sub.endpoint).host} failed: ${res.status}`); + } + } catch (err) { + console.warn(`push to ${new URL(sub.endpoint).host} failed:`, (err as Error).message); + } + }), + ); +} diff --git a/apps/api/src/services/queues.ts b/apps/api/src/services/queues.ts index ac122d2..74d675c 100644 --- a/apps/api/src/services/queues.ts +++ b/apps/api/src/services/queues.ts @@ -5,12 +5,14 @@ const connection = createRedis(); export const POLL_QUEUE = 'poll'; export const RESOLVE_QUEUE = 'resolve-track'; +export const PUSH_QUEUE = 'push'; export const pollQueue = new Queue<{ connectionId: string }>(POLL_QUEUE, { connection }); export const resolveQueue = new Queue<{ trackId: string }>(RESOLVE_QUEUE, { connection }); +export const pushQueue = new Queue<{ notificationId: string }>(PUSH_QUEUE, { connection }); export async function closeQueues() { - await Promise.all([pollQueue.close(), resolveQueue.close()]); + await Promise.all([pollQueue.close(), resolveQueue.close(), pushQueue.close()]); await connection.quit(); } @@ -27,3 +29,8 @@ export function enqueueResolve(trackId: string) { }, ); } + +/** Push a notification to the user's devices (the worker sends it). */ +export function enqueuePush(notificationId: string) { + return pushQueue.add('push', { notificationId }, { attempts: 2, backoff: { type: 'fixed', delay: 30_000 }, removeOnComplete: true, removeOnFail: 1000 }); +} diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 1bac219..3d73ca7 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -8,7 +8,8 @@ import { createRedis } from './lib/redis'; import { POLLED_PROVIDERS, pollingProviders } from './providers'; import { ProviderAuthError, ProviderRateLimitError } from './providers/types'; import { applyPollResult, isSharingPaused } from './services/nowPlaying'; -import { POLL_QUEUE, RESOLVE_QUEUE, pollQueue } from './services/queues'; +import { sendPush } from './services/notifications'; +import { POLL_QUEUE, PUSH_QUEUE, RESOLVE_QUEUE, pollQueue } from './services/queues'; import { resolveTrackLinks } from './services/resolver'; import { IDLE_POLL_SECONDS, @@ -116,7 +117,11 @@ const resolveWorker = new Worker<{ trackId: string }>(RESOLVE_QUEUE, (job) => re connection: createRedis(), concurrency: 5, }); -for (const w of [pollWorker, resolveWorker]) w.on('failed', (job, err) => log(`${w.name} job ${job?.id} failed`, err.message)); +const pushWorker = new Worker<{ notificationId: string }>(PUSH_QUEUE, (job) => sendPush(job.data.notificationId), { + connection: createRedis(), + concurrency: 10, +}); +for (const w of [pollWorker, resolveWorker, pushWorker]) w.on('failed', (job, err) => log(`${w.name} job ${job?.id} failed`, err.message)); await reconcile(); const tickTimer = setInterval(tick, 1000); @@ -126,7 +131,7 @@ log('worker started'); async function shutdown() { clearInterval(tickTimer); clearInterval(reconcileTimer); - await Promise.all([pollWorker.close(), resolveWorker.close()]); + await Promise.all([pollWorker.close(), resolveWorker.close(), pushWorker.close()]); await closeConnections(); process.exit(0); } diff --git a/apps/api/test/notifications.test.ts b/apps/api/test/notifications.test.ts new file mode 100644 index 0000000..c55591a --- /dev/null +++ b/apps/api/test/notifications.test.ts @@ -0,0 +1,126 @@ +import { createECDH, randomBytes } from 'node:crypto'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { applyPollResult } from '../src/services/nowPlaying'; +import { sendPush } from '../src/services/notifications'; +import { bearer, createUser, follow, useTestApp } from './helpers'; + +const app = useTestApp(); +afterEach(() => vi.unstubAllGlobals()); + +const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null }); +const inbox = async (token: string) => (await app().inject({ url: '/notifications', headers: bearer(token) })).json(); +const react = (token: string, listenId: string, emoji: string) => + app().inject({ method: 'PUT', url: `/listens/${listenId}/reaction`, headers: bearer(token), payload: { emoji } }); + +/** Alice plays a song; Bob follows her. */ +async function setup() { + const alice = await createUser(app(), 'alice'); + const bob = await createUser(app(), 'bob'); + await follow(app(), bob.token, 'alice'); + await applyPollResult(alice.user.id, 'lastfm', song('Teardrop')); + const feed = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json(); + return { alice, bob, listenId: feed[0].listenId as string, trackId: feed[0].track.id as string }; +} + +describe('notifications', () => { + it('tells you about a new follower once, even if they unfollow and follow again', async () => { + const { alice, bob } = await setup(); + await app().inject({ method: 'DELETE', url: '/users/alice/follow', headers: bearer(bob.token) }); + await follow(app(), bob.token, 'alice'); + const list = await inbox(alice.token); + expect(list.unread).toBe(1); + expect(list.notifications).toEqual([expect.objectContaining({ type: 'follow', actor: expect.objectContaining({ username: 'bob' }), read: false })]); + }); + + it('a reaction is one notification that changes with the emoji, and goes if taken back', async () => { + const { alice, bob, listenId } = await setup(); + await app().inject({ method: 'POST', url: '/notifications/read', headers: bearer(alice.token), payload: {} }); + await react(bob.token, listenId, '๐Ÿ”ฅ'); + await react(bob.token, listenId, '๐Ÿ˜‚'); + let list = await inbox(alice.token); + const reactions = list.notifications.filter((n: { type: string }) => n.type === 'reaction'); + expect(reactions).toEqual([expect.objectContaining({ emoji: '๐Ÿ˜‚', track: expect.objectContaining({ title: 'Teardrop' }) })]); + expect(list.unread).toBe(1); + + await app().inject({ method: 'DELETE', url: `/listens/${listenId}/reaction`, headers: bearer(bob.token) }); + list = await inbox(alice.token); + expect(list.notifications.filter((n: { type: string }) => n.type === 'reaction')).toEqual([]); + }); + + it('sends songs only to people who follow you', async () => { + const { alice, bob, trackId } = await setup(); + const send = (token: string, to: string[]) => + app().inject({ method: 'POST', url: '/shares', headers: bearer(token), payload: { trackId, to, message: 'you need this' } }); + + // Bob follows Alice, so Alice can send to Bob, but Bob can't send to Alice. + expect((await send(alice.token, ['bob', 'nobody'])).json()).toEqual({ sentTo: ['bob'] }); + expect((await send(bob.token, ['alice'])).statusCode).toBe(400); + + const list = await inbox(bob.token); + expect(list.notifications[0]).toMatchObject({ + type: 'share', + message: 'you need this', + actor: { username: 'alice' }, + track: { title: 'Teardrop' }, + }); + await app().inject({ method: 'POST', url: '/notifications/read', headers: bearer(bob.token), payload: {} }); + expect((await inbox(bob.token)).unread).toBe(0); + }); + + it('saves notification settings', async () => { + const { alice } = await setup(); + const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false } } })).json(); + expect(me.notifyPrefs).toEqual({ reaction: true, follow: false, share: true }); + }); +}); + +describe('push', () => { + // A real P-256 key pair, as a browser or the UnifiedPush connector makes. + const ecdh = createECDH('prime256v1'); + ecdh.generateKeys(); + const keys = { p256dh: ecdh.getPublicKey('base64url'), auth: randomBytes(16).toString('base64url') }; + const subscribe = (token: string, endpoint: string) => + app().inject({ method: 'PUT', url: '/me/push', headers: bearer(token), payload: { kind: 'unifiedpush', endpoint, keys } }); + + it('refuses push endpoints on private networks', async () => { + const alice = await createUser(app(), 'alice'); + expect((await subscribe(alice.token, 'http://127.0.0.1:8080/up/abc')).statusCode).toBe(400); + expect((await subscribe(alice.token, 'https://93.184.216.34/up/abc')).statusCode).toBe(200); + }); + + it('sends an encrypted, signed push, respects settings, and forgets endpoints that are gone', async () => { + const { alice } = await setup(); + await subscribe(alice.token, 'https://93.184.216.34/up/abc'); + const id = (await inbox(alice.token)).notifications[0].id; + + const sent: { url: string; headers: Record; body: Buffer }[] = []; + let status = 201; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + sent.push({ url, headers: init.headers as Record, body: init.body as Buffer }); + return new Response(null, { status }); + }); + + await sendPush(id); + expect(sent).toHaveLength(1); + expect(sent[0]!.url).toBe('https://93.184.216.34/up/abc'); + expect(sent[0]!.headers['Content-Encoding']).toBe('aes128gcm'); + expect(sent[0]!.headers.Authorization).toMatch(/^vapid t=/); + expect(sent[0]!.body.toString()).not.toContain('bob'); // encrypted + + await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false } } }); + await sendPush(id); + expect(sent).toHaveLength(1); + + await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: true } } }); + status = 410; + await sendPush(id); + expect(sent).toHaveLength(2); + await sendPush(id); + expect(sent).toHaveLength(2); // subscription removed + }); + + it('serves the public key', async () => { + const res = await app().inject({ url: '/push/key' }); + expect(res.json().publicKey).toMatch(/^[A-Za-z0-9_-]{80,}$/); + }); +}); diff --git a/apps/mobile/modules/push/android/build.gradle b/apps/mobile/modules/push/android/build.gradle new file mode 100644 index 0000000..5393f2e --- /dev/null +++ b/apps/mobile/modules/push/android/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'tech.chrisedwards.musicshare.push' +version = '0.1.0' + +android { + namespace "tech.chrisedwards.musicshare.push" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} + +dependencies { + // UnifiedPush: notifications through a distributor app the user picks (e.g. ntfy on our own + // server) instead of Google's Firebase. Open source (Apache-2.0), no Play Services. + implementation "org.unifiedpush.android:connector:3.3.5" + implementation "androidx.core:core-ktx:1.16.0" +} diff --git a/apps/mobile/modules/push/android/src/main/AndroidManifest.xml b/apps/mobile/modules/push/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5e7b88e --- /dev/null +++ b/apps/mobile/modules/push/android/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/apps/mobile/modules/push/android/src/main/java/tech/chrisedwards/musicshare/push/MusicsharePushService.kt b/apps/mobile/modules/push/android/src/main/java/tech/chrisedwards/musicshare/push/MusicsharePushService.kt new file mode 100644 index 0000000..a2317a5 --- /dev/null +++ b/apps/mobile/modules/push/android/src/main/java/tech/chrisedwards/musicshare/push/MusicsharePushService.kt @@ -0,0 +1,88 @@ +package tech.chrisedwards.musicshare.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import org.json.JSONObject +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +/** + * Runs without the app UI: the distributor (e.g. ntfy) wakes it with new endpoints and + * messages. Messages arrive already decrypted by the connector library. + */ +class MusicsharePushService : PushService() { + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + val keys = endpoint.pubKeySet ?: run { + Log.w(TAG, "endpoint without keys; can't receive encrypted pushes") + return + } + val ep = Endpoint(endpoint.url, keys.pubKey, keys.auth) + PushPrefs(this).endpoint = ep + PushBus.emit(ep) + } + + override fun onMessage(message: PushMessage, instance: String) { + try { + show(this, JSONObject(String(message.content, Charsets.UTF_8))) + } catch (e: Exception) { + Log.w(TAG, "couldn't show push", e) + } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + Log.w(TAG, "registration failed: $reason") + forget() + } + + override fun onUnregistered(instance: String) = forget() + + private fun forget() { + PushPrefs(this).endpoint = null + PushBus.emit(null) + } + + companion object { + private const val TAG = "MusicsharePush" + private const val CHANNEL = "activity" + + /** { id, title, body, path } from the server; tapping opens musicshare://. */ + fun show(context: Context, data: JSONObject) { + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(CHANNEL, "Activity", NotificationManager.IMPORTANCE_DEFAULT) + channel.description = "Reactions, new followers and songs sent to you" + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + val id = data.optString("id", System.currentTimeMillis().toString()) + val open = Intent(Intent.ACTION_VIEW, Uri.parse("musicshare:/" + data.optString("path", "/"))) + .setPackage(context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val tap = PendingIntent.getActivity(context, id.hashCode(), open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + val notification = NotificationCompat.Builder(context, CHANNEL) + .setSmallIcon(R.drawable.ic_stat_musicshare) + .setColor(0xFF7C4DFF.toInt()) + .setContentTitle(data.optString("title")) + .setContentText(data.optString("body")) + .setStyle(NotificationCompat.BigTextStyle().bigText(data.optString("body"))) + .setContentIntent(tap) + .setAutoCancel(true) + .build() + try { + manager.notify(id.hashCode(), notification) + } catch (e: SecurityException) { + Log.w(TAG, "notification permission missing", e) + } + } + } +} diff --git a/apps/mobile/modules/push/android/src/main/java/tech/chrisedwards/musicshare/push/PushModule.kt b/apps/mobile/modules/push/android/src/main/java/tech/chrisedwards/musicshare/push/PushModule.kt new file mode 100644 index 0000000..09fe78a --- /dev/null +++ b/apps/mobile/modules/push/android/src/main/java/tech/chrisedwards/musicshare/push/PushModule.kt @@ -0,0 +1,49 @@ +package tech.chrisedwards.musicshare.push + +import expo.modules.kotlin.Promise +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import org.unifiedpush.android.connector.UnifiedPush + +class PushModule : Module() { + private val context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost() + + private val listener: (Endpoint?) -> Unit = { sendEvent("onEndpoint", mapOf("endpoint" to it?.toMap())) } + + override fun definition() = ModuleDefinition { + Name("MusicsharePush") + + Events("onEndpoint") + + OnStartObserving { PushBus.add(listener) } + OnStopObserving { PushBus.remove(listener) } + + /** Package names of installed UnifiedPush distributors (ntfy etc.). */ + Function("distributors") { UnifiedPush.getDistributors(context) } + + /** The distributor in use, if registered. */ + Function("currentDistributor") { UnifiedPush.getAckDistributor(context) } + + /** The endpoint to give the server: { url, p256dh, auth }, or null. */ + Function("getEndpoint") { PushPrefs(context).endpoint?.toMap() } + + /** + * Picks a distributor (asks if there are several) and registers. Resolves false when + * there's none installed. The endpoint arrives later through onEndpoint. + */ + AsyncFunction("register") { vapidPublicKey: String, promise: Promise -> + // It may ask the user to pick a distributor, so it needs the app on screen. + val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity() + UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { ok -> + if (ok) UnifiedPush.register(context, "default", "musicshare notifications", vapidPublicKey) + promise.resolve(ok) + } + } + + Function("unregister") { + UnifiedPush.unregister(context, "default") + PushPrefs(context).endpoint = null + } + } +} diff --git a/apps/mobile/modules/push/android/src/main/java/tech/chrisedwards/musicshare/push/PushPrefs.kt b/apps/mobile/modules/push/android/src/main/java/tech/chrisedwards/musicshare/push/PushPrefs.kt new file mode 100644 index 0000000..64c93c4 --- /dev/null +++ b/apps/mobile/modules/push/android/src/main/java/tech/chrisedwards/musicshare/push/PushPrefs.kt @@ -0,0 +1,31 @@ +package tech.chrisedwards.musicshare.push + +import android.content.Context + +/** The current push endpoint, kept so JS can send it to the server whenever the app opens. */ +internal class PushPrefs(context: Context) { + private val prefs = context.applicationContext.getSharedPreferences("musicshare.push", Context.MODE_PRIVATE) + + var endpoint: Endpoint? + get() { + val url = prefs.getString("url", null) ?: return null + return Endpoint(url, prefs.getString("p256dh", null) ?: return null, prefs.getString("auth", null) ?: return null) + } + set(value) { + val edit = prefs.edit() + if (value == null) edit.clear() else edit.putString("url", value.url).putString("p256dh", value.p256dh).putString("auth", value.auth) + edit.apply() + } +} + +internal data class Endpoint(val url: String, val p256dh: String, val auth: String) { + fun toMap() = mapOf("url" to url, "p256dh" to p256dh, "auth" to auth) +} + +/** Tells the JS module (when the app is open) that the endpoint changed. */ +internal object PushBus { + private val listeners = mutableSetOf<(Endpoint?) -> Unit>() + @Synchronized fun add(listener: (Endpoint?) -> Unit) { listeners.add(listener) } + @Synchronized fun remove(listener: (Endpoint?) -> Unit) { listeners.remove(listener) } + @Synchronized fun emit(endpoint: Endpoint?) { listeners.forEach { it(endpoint) } } +} diff --git a/apps/mobile/modules/push/android/src/main/res/drawable/ic_stat_musicshare.xml b/apps/mobile/modules/push/android/src/main/res/drawable/ic_stat_musicshare.xml new file mode 100644 index 0000000..931e3af --- /dev/null +++ b/apps/mobile/modules/push/android/src/main/res/drawable/ic_stat_musicshare.xml @@ -0,0 +1,10 @@ + + + + diff --git a/apps/mobile/modules/push/expo-module.config.json b/apps/mobile/modules/push/expo-module.config.json new file mode 100644 index 0000000..2833545 --- /dev/null +++ b/apps/mobile/modules/push/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["tech.chrisedwards.musicshare.push.PushModule"] + } +} diff --git a/apps/mobile/modules/push/index.ts b/apps/mobile/modules/push/index.ts new file mode 100644 index 0000000..ba20c22 --- /dev/null +++ b/apps/mobile/modules/push/index.ts @@ -0,0 +1,3 @@ +// Android-only native module; the default export is null where it is unavailable. +export { default } from './src/PushModule'; +export type { PushEndpoint } from './src/PushModule'; diff --git a/apps/mobile/modules/push/src/PushModule.ts b/apps/mobile/modules/push/src/PushModule.ts new file mode 100644 index 0000000..a16ee7d --- /dev/null +++ b/apps/mobile/modules/push/src/PushModule.ts @@ -0,0 +1,20 @@ +import { NativeModule, requireOptionalNativeModule } from 'expo'; + +export interface PushEndpoint { + url: string; + p256dh: string; + auth: string; +} + +type Events = { onEndpoint: (event: { endpoint: PushEndpoint | null }) => void }; + +declare class MusicsharePushModule extends NativeModule { + distributors(): string[]; + currentDistributor(): string | null; + getEndpoint(): PushEndpoint | null; + register(vapidPublicKey: string): Promise; + unregister(): void; +} + +/** Android only (UnifiedPush); null elsewhere and in Expo Go. */ +export default requireOptionalNativeModule('MusicsharePush'); diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index a493a51..70780fb 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -3,9 +3,10 @@ import { useEffect, useState } from 'react'; import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; -import useSWR from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared'; import { AmbientBackground } from '@/components/AmbientBackground'; +import { ActivityBell } from '@/components/ActivityBell'; import { Avatar } from '@/components/Avatar'; import { NowPlayingCard } from '@/components/NowPlayingCard'; import { RelinkReminder } from '@/components/RelinkReminder'; @@ -18,10 +19,11 @@ import { useReader } from '@/lib/nowPlayingReader'; import { shouldOfferSetup } from '@/lib/setup'; import { useRealtime } from '@/lib/useRealtime'; import { useSession } from '@/lib/session'; -import { colors, space } from '@/lib/theme'; +import { colors, fonts, space } from '@/lib/theme'; export default function FeedScreen() { const { me } = useSession(); + const { mutate: mutateGlobal } = useSWRConfig(); const { data: feed, mutate, isLoading } = useSWR('/feed'); // Your own profile, for the "You" card. Loading it also marks you as watched, so your updates arrive quickly. const { data: mine, mutate: mutateMine } = useSWR(me ? `/users/${me.username}` : null); @@ -44,7 +46,10 @@ export default function FeedScreen() { }, []); useRealtime(true, (msg) => { - if (msg.type !== 'now_playing') return; + if (msg.type === 'notification') { + mutateGlobal('/notifications'); + return; + } if (msg.data.user.id === me?.id) { if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false }); return; @@ -88,6 +93,10 @@ export default function FeedScreen() { refreshControl={} ListHeaderComponent={ + + musicshare + + {offerAppleMusic && ( @@ -170,6 +179,8 @@ const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: colors.bg }, list: { padding: space.lg, gap: space.md, paddingBottom: space.xl * 3 }, header: { gap: space.md, marginBottom: space.xs }, + topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + brand: { color: colors.text, fontFamily: fonts.display, fontSize: 22, letterSpacing: -0.4 }, sectionLabel: { color: colors.muted, fontSize: 12, fontWeight: '700', letterSpacing: 0.8 }, quiet: { gap: space.lg, paddingVertical: space.xs }, quietPerson: { width: 64, alignItems: 'center', gap: 4 }, diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 172d51d..b875021 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -11,6 +11,9 @@ import { type MeDTO, type NowPlayingProvider, type StreamingService, + type NotificationType, + NOTIFICATION_PREF_LABELS, + NOTIFICATION_TYPES, } from '@musicshare/shared'; import { Avatar } from '@/components/Avatar'; import { RelinkReminder } from '@/components/RelinkReminder'; @@ -25,6 +28,7 @@ import { showError } from '@/lib/errors'; import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic'; import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect'; import { openReaderSettings, useReader } from '@/lib/nowPlayingReader'; +import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push'; import { useSession } from '@/lib/session'; import { colors, radius, space } from '@/lib/theme'; @@ -36,6 +40,7 @@ export default function SettingsScreen() { + @@ -161,6 +166,75 @@ function MyServices() { ); } +/** What to be notified about, and push delivery on this phone. */ +function Notifications() { + const { me, setMe } = useSession(); + const push = usePushStatus(); + const [busy, setBusy] = useState(false); + if (!me) return null; + + const toggle = async (type: NotificationType) => { + try { + setMe(await api.updateMe({ notifyPrefs: { [type]: !me.notifyPrefs[type] } })); + } catch (err) { + showError(err); + } + }; + const turnOn = async () => { + setBusy(true); + try { + const result = await enablePush(); + if (result === 'denied') Alert.alert('Notifications are off', 'Allow notifications for musicshare in Android settings, then try again.'); + if (result === 'no-distributor') push.refresh(); + } catch (err) { + showError(err); + } + setBusy(false); + }; + const turnOff = async () => { + setBusy(true); + await disablePush().catch((err) => showError(err)); + push.refresh(); + setBusy(false); + }; + + return ( + + Notifications + + Notify me about: + + + {NOTIFICATION_TYPES.map((t) => ( + toggle(t)} /> + ))} + + {pushAvailable && + (push.enabled ? ( + <> + + On for this phone{push.distributor ? `, delivered by ${push.distributor}` : ''}. + + + )} + + ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index bf88425..a20f28e 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from 'motion/react'; import Link from 'next/link'; -import useSWR from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; import { sortFeed, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared'; import { Avatar } from '@/components/Avatar'; import { Equalizer } from '@/components/Equalizer'; @@ -20,6 +20,7 @@ export default function Home() { function Feed() { const { me } = useMe(); + const { mutate: mutateGlobal } = useSWRConfig(); const { data: feed, mutate } = useSWR('/feed', fetcher); // Your own profile, for the "You" card. Loading it also marks you as watched, so your updates arrive quickly. const { data: mine, mutate: mutateMine } = useSWR(me ? `/users/${me.username}` : null, fetcher); @@ -29,7 +30,10 @@ function Feed() { const quiet = following?.users.filter((u) => !inFeed.has(u.id)) ?? []; useRealtime(true, (msg) => { - if (msg.type !== 'now_playing') return; + if (msg.type === 'notification') { + mutateGlobal('/notifications'); + return; + } if (msg.data.user.id === me?.id) { if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false }); return; diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 1066b15..def6cde 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; export const metadata: Metadata = { title: 'Privacy policy ยท musicshare' }; -const UPDATED = 'October 6, 2026'; +const UPDATED = 'October 14, 2026'; const CONTACT = 'app@musicshare.binarygnome.com'; export default function PrivacyPage() { @@ -43,6 +43,11 @@ export default function PrivacyPage() {
  • People: who you follow, who you've pinned, who you've blocked, and reports you send.
  • +
  • + Notifications: reactions, new followers and songs (with any note) sent to you, and your + notification settings. If you turn on push notifications, we store the push address your browser or + phone gives us and its encryption keys. +
  • Technical data: sign-in sessions (stored as hashes), and your IP address, which is used briefly to prevent abuse (like limiting sign-in attempts) and appears in server logs that are kept for a short time. @@ -82,7 +87,13 @@ export default function PrivacyPage() {
  • Song matching: to build “open in your app” links we look up song titles and artists with Deezer, Apple's iTunes search, Spotify's catalog and Odesli (song.link). These lookups contain only - the song, never who listened to it. + the song, never who listened to it. Lyrics come from LRCLIB, looked up the same way: just the song. +
  • +
  • + Push notifications: if you turn them on, notifications are encrypted on our server and + delivered through your browser maker's push service (for example Google for Chrome, Mozilla for Firefox) or, + on Android, through the push server your ntfy app uses (ours, unless you chose another). They can't read + the contents.
  • Our own servers: the app, database and email run on servers we manage ourselves. @@ -97,6 +108,7 @@ export default function PrivacyPage() {

    How long we keep it

    • Recent plays: the latest 50. Older ones are deleted as new ones come in.
    • +
    • Notifications: until you delete your account. Push addresses are deleted when you turn push off or the device stops accepting them.
    • Everything else: until you delete it or your account. Deleting your account (Settings → Delete account) removes your profile, picture, plays, follows and connected services right away. Database backups are kept for diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 28f1e02..c99f5c4 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useRef, useState } from 'react'; +import { Suspense, useEffect, useRef, useState } from 'react'; import useSWR from 'swr'; import { SERVICE_LABELS, @@ -14,6 +14,9 @@ import { type NowPlayingProvider, type ProfileDTO, type StreamingService, + type NotificationType, + NOTIFICATION_PREF_LABELS, + NOTIFICATION_TYPES, type TrackSearchResultDTO, type UserSummaryDTO, } from '@musicshare/shared'; @@ -22,6 +25,7 @@ import { ServiceName } from '@/components/ServiceName'; import { SharingControls } from '@/components/SharingControls'; import { DONATION_URL } from '@/components/SupportLink'; import { api, client, fetcher, uploadFile, useMe } from '@/lib/api'; +import { browserPushSupported, currentSubscription, disableBrowserPush, enableBrowserPush } from '@/lib/push'; import { timeAgo } from '@musicshare/shared'; import { useDebounced } from '@/lib/useDebounced'; @@ -43,6 +47,7 @@ function Settings() {

      Settings

      + @@ -503,6 +508,71 @@ function FeaturedPicker() { type Panel = 'email' | 'password' | 'blocked' | 'delete' | null; /** Everything about the account itself, as one list of rows; each opens in place. */ +/** What to be notified about, and push in this browser. */ +function NotificationSettings({ me }: { me: MeDTO }) { + const { mutate } = useMe(); + const [push, setPush] = useState<'unsupported' | 'off' | 'on' | 'blocked' | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!browserPushSupported()) return setPush('unsupported'); + if (Notification.permission === 'denied') return setPush('blocked'); + currentSubscription().then((s) => setPush(s ? 'on' : 'off'), () => setPush('off')); + }, []); + + const toggle = async (type: NotificationType) => { + try { + await mutate(await client.updateMe({ notifyPrefs: { [type]: !me.notifyPrefs[type] } }), { revalidate: false }); + } catch (err) { + setError((err as Error).message); + } + }; + const setBrowser = async (on: boolean) => { + setBusy(true); + setError(null); + try { + if (on) setPush((await enableBrowserPush()) ? 'on' : 'blocked'); + else { + await disableBrowserPush(); + setPush('off'); + } + } catch (err) { + setError((err as Error).message); + } + setBusy(false); + }; + + return ( +
      +

      Notifications

      + Notify me about: + {NOTIFICATION_TYPES.map((t) => ( + + ))} + {push === 'on' && ( +

      + On in this browser.{' '} + +

      + )} + {push === 'off' && ( + + )} + {push === 'blocked' &&

      This browser is blocking notifications from musicshare; allow them in its site settings.

      } + {push === 'unsupported' &&

      This browser can't show notifications. Everything still appears under the bell.

      } + {error &&

      {error}

      } +
      + ); +} + function AccountCard({ me }: { me: MeDTO }) { const { mutate } = useMe(); const router = useRouter(); diff --git a/apps/web/src/components/ActivityBell.tsx b/apps/web/src/components/ActivityBell.tsx new file mode 100644 index 0000000..614232f --- /dev/null +++ b/apps/web/src/components/ActivityBell.tsx @@ -0,0 +1,21 @@ +'use client'; + +import Link from 'next/link'; +import useSWR from 'swr'; +import type { NotificationListDTO } from '@musicshare/shared'; +import { fetcher } from '@/lib/api'; + +/** Bell with the unread count; opens your notifications. */ +export function ActivityBell() { + const { data } = useSWR('/notifications', fetcher, { refreshInterval: 60_000 }); + const unread = data?.unread ?? 0; + return ( + + + + + + {unread > 0 && {unread > 99 ? '99+' : unread}} + + ); +} diff --git a/apps/web/src/components/Nav.tsx b/apps/web/src/components/Nav.tsx index b80eaee..17b0a54 100644 --- a/apps/web/src/components/Nav.tsx +++ b/apps/web/src/components/Nav.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useState } from 'react'; import { client, useMe } from '@/lib/api'; +import { ActivityBell } from './ActivityBell'; /** Top bar: logo and the four main places. On phones those move to a bottom tab bar. */ export function Nav() { @@ -25,6 +26,7 @@ export function Nav() { musicshare + {me && }
      {me ? ( tabs.map((t) => ( diff --git a/apps/web/src/components/SendSong.tsx b/apps/web/src/components/SendSong.tsx new file mode 100644 index 0000000..5f0795e --- /dev/null +++ b/apps/web/src/components/SendSong.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useRef, useState } from 'react'; +import useSWRInfinite from 'swr/infinite'; +import type { FollowListDTO, TrackDTO } from '@musicshare/shared'; +import { client, useMe } from '@/lib/api'; +import { Avatar } from './Avatar'; + +/** "Send โœ‰": pick followers and send them the song, with an optional note. */ +export function SendSongButton({ track }: { track: TrackDTO }) { + const { me } = useMe(); + const dialog = useRef(null); + const [open, setOpen] = useState(false); + if (!me) return null; + return ( + <> + + setOpen(false)} onClick={(e) => e.target === dialog.current && dialog.current?.close()}> + {open && dialog.current?.close()} />} + + + ); +} + +function SendForm({ track, username, close }: { track: TrackDTO; username: string; close: () => void }) { + const { data, size, setSize, isLoading } = useSWRInfinite( + (i, prev: FollowListDTO | null) => (i > 0 && !prev?.nextCursor ? null : ['followers', username, prev?.nextCursor ?? null]), + ([, u, cursor]: [string, string, string | null]) => client.followers(u, cursor), + ); + const followers = data?.flatMap((p) => p.users) ?? []; + const [picked, setPicked] = useState>(new Set()); + const [message, setMessage] = useState(''); + const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle'); + const [error, setError] = useState(null); + + const toggle = (u: string) => + setPicked((p) => { + const next = new Set(p); + if (next.has(u)) next.delete(u); + else next.add(u); + return next; + }); + + const send = async (e: React.FormEvent) => { + e.preventDefault(); + setState('sending'); + setError(null); + try { + await client.sendSong({ trackId: track.id, to: [...picked], message: message.trim() || null }); + setState('sent'); + setTimeout(close, 900); + } catch (err) { + setError((err as Error).message); + setState('idle'); + } + }; + + return ( +
      +

      Send to friends

      +

      + {track.title} ยท {track.artist} +

      + setMessage(e.target.value)} maxLength={280} /> +

      You can send songs to people who follow you.

      + {followers.length === 0 && !isLoading &&

      No followers yet.

      } +
        + {followers.map((u) => ( +
      • + +
      • + ))} +
      + {data?.at(-1)?.nextCursor && ( + + )} + {error &&

      {error}

      } +
      + + +
      +
      + ); +} diff --git a/apps/web/src/components/TrackCard.tsx b/apps/web/src/components/TrackCard.tsx index 47a428c..b0fc7a9 100644 --- a/apps/web/src/components/TrackCard.tsx +++ b/apps/web/src/components/TrackCard.tsx @@ -2,6 +2,7 @@ import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared'; import { useMe } from '@/lib/api'; +import { SendSongButton } from './SendSong'; import { ShareButton } from './ShareButton'; function Artwork({ track, small }: { track: TrackDTO; small?: boolean }) { @@ -41,6 +42,7 @@ export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred: ); })} + ); diff --git a/apps/web/src/lib/push.ts b/apps/web/src/lib/push.ts new file mode 100644 index 0000000..7980b57 --- /dev/null +++ b/apps/web/src/lib/push.ts @@ -0,0 +1,41 @@ +'use client'; + +import { client } from './api'; + +/** Browser push needs a service worker (production builds register /sw.js) and the Push API. */ +export const browserPushSupported = () => + typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; + +async function registration() { + return (await navigator.serviceWorker.getRegistration('/')) ?? navigator.serviceWorker.register('/sw.js', { scope: '/', updateViaCache: 'none' }); +} + +/** This browser's push subscription, if any. */ +export async function currentSubscription(): Promise { + if (!browserPushSupported()) return null; + return (await registration()).pushManager.getSubscription(); +} + +const toBytes = (base64url: string) => { + const raw = atob(base64url.replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(raw, (c) => c.charCodeAt(0)); +}; + +/** Ask for permission, subscribe, and tell the server. Returns false if the user said no. */ +export async function enableBrowserPush(): Promise { + if ((await Notification.requestPermission()) !== 'granted') return false; + const { publicKey } = await client.pushKey(); + const reg = await registration(); + await navigator.serviceWorker.ready; + const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(publicKey) }); + const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } }; + await client.addPushSubscription({ kind: 'web', endpoint: json.endpoint, keys: json.keys }); + return true; +} + +export async function disableBrowserPush() { + const sub = await currentSubscription(); + if (!sub) return; + await client.removePushSubscription(sub.endpoint).catch(() => {}); + await sub.unsubscribe(); +} diff --git a/deploy/production.env.example b/deploy/production.env.example index fc76361..4f57ecf 100644 --- a/deploy/production.env.example +++ b/deploy/production.env.example @@ -34,3 +34,12 @@ MAIL_FROM=musicshare PROXY_BIND=127.0.0.1 WEB_PORT=3737 API_PORT=3738 + +# Phone notifications through your own ntfy server (docker-compose.ntfy.yml, docs/PUSH.md). +# A subdomain pointed at it, e.g. push.example.com. +NTFY_DOMAIN= +# Web Push keys (optional; made automatically and kept in the database if empty). +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +# Lyrics: LRCLIB (or your own copy of it). Empty turns lyrics off. +LRCLIB_URL=https://lrclib.net diff --git a/docker-compose.ntfy.yml b/docker-compose.ntfy.yml new file mode 100644 index 0000000..ce9ceb4 --- /dev/null +++ b/docker-compose.ntfy.yml @@ -0,0 +1,41 @@ +# Your own push server for phone notifications (UnifiedPush via ntfy), so nothing goes +# through Google. Add this file to the usual production command, e.g.: +# +# docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.behind-proxy.yml \ +# -f docker-compose.ntfy.yml --env-file deploy/production.env up -d +# +# Then point a subdomain (NTFY_DOMAIN, e.g. push.example.com) at 127.0.0.1:${NTFY_PORT:-3739} in your +# reverse proxy, with websockets on. ntfy can't live under a path like /push. See docs/PUSH.md. +services: + ntfy: + image: binwiederhier/ntfy:v2.30.0 + command: serve + restart: unless-stopped + environment: + NTFY_BASE_URL: https://${NTFY_DOMAIN:?set NTFY_DOMAIN in production.env} + NTFY_BEHIND_PROXY: 'true' + # Keep messages for phones that are offline for a while. + NTFY_CACHE_FILE: /var/cache/ntfy/cache.db + NTFY_CACHE_DURATION: 24h + # Private server: nobody can use it for anything except UnifiedPush topics ("up" + a long + # random name only the phone and our server know). + NTFY_AUTH_FILE: /var/lib/ntfy/auth.db + NTFY_AUTH_DEFAULT_ACCESS: deny-all + NTFY_AUTH_ACCESS: '*:up*:rw' + NTFY_ENABLE_SIGNUP: 'false' + NTFY_ENABLE_LOGIN: 'false' + NTFY_WEB_ROOT: disable + volumes: + - ntfy_cache:/var/cache/ntfy + - ntfy_lib:/var/lib/ntfy + ports: + - "${PROXY_BIND:-127.0.0.1}:${NTFY_PORT:-3739}:80" + healthcheck: + test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -q '\"healthy\":true'"] + interval: 60s + timeout: 10s + retries: 3 + +volumes: + ntfy_cache: + ntfy_lib: diff --git a/docs/PUSH.md b/docs/PUSH.md new file mode 100644 index 0000000..030faa5 --- /dev/null +++ b/docs/PUSH.md @@ -0,0 +1,45 @@ +# Notifications + +musicshare tells people about reactions to their songs, new followers, and songs sent to them. Each user picks +which kinds in Settings. Everything always shows under the bell on the feed; push notifications are extra. + +Nothing goes through Google's Firebase or another push company's account: + +- **Phones (Android):** [UnifiedPush](https://unifiedpush.org). A "distributor" app on the phone keeps one + connection open to a push server, and apps receive their notifications through it. We run the push server + ourselves ([ntfy](https://ntfy.sh), `docker-compose.ntfy.yml`). People install the ntfy app (free, open source, + on F-Droid and Play) and point it at our server. +- **Browsers:** standard Web Push. The server signs messages with its own key pair (VAPID), so there's no account + with anyone, but each browser maker runs its own delivery service (Chrome uses Google's, Firefox uses Mozilla's). + That's built into browsers and can't be swapped. +- **iPhone:** Apple only allows its own push service. Not set up. + +Messages are end-to-end encrypted (RFC 8291) in every case: the push server and browser relays only see ciphertext. + +## Server setup + +1. Pick a subdomain for the push server, e.g. `push.binarygnome.com`, and point DNS at the server. +2. Set `NTFY_DOMAIN` in `deploy/production.env`. +3. Start it with the other services. Add `-f docker-compose.ntfy.yml` to the usual command: + ```bash + docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.behind-proxy.yml \ + -f docker-compose.ntfy.yml --env-file deploy/production.env up -d + ``` +4. In the reverse proxy (NPMplus), add a proxy host for that subdomain: + - Forward to `http://127.0.0.1:3739`. + - Turn on websocket support and HTTPS. +5. Check it: `curl https://push.binarygnome.com/v1/health` should say `{"healthy":true}`. + +The server's Web Push keys are generated the first time they're needed and kept in the `server_keys` table. Don't +delete that row: every phone and browser would have to turn notifications on again. To manage the keys yourself, +set `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` (make a pair with `npx web-push generate-vapid-keys`). + +## On a phone + +1. Install **ntfy** ([F-Droid](https://f-droid.org/packages/io.heckel.ntfy/) or Play). +2. In ntfy, open **Settings > Default server** and enter `https://push.binarygnome.com`. Without this step it + uses the public ntfy.sh server, which also works, but then the push server isn't ours. +3. In musicshare, go to **Settings > Notifications > Turn on for this phone**. + +ntfy shows one quiet, permanent notification so Android keeps its connection alive. People can hide it by +long-pressing it and turning off that notification category. diff --git a/package-lock.json b/package-lock.json index 405c3c0..e78f14a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "name": "musicshare", + "license": "AGPL-3.0-or-later", "workspaces": [ "apps/*", "packages/*" @@ -20,6 +21,7 @@ "apps/api": { "name": "@musicshare/api", "version": "0.0.0", + "license": "AGPL-3.0-or-later", "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", @@ -37,11 +39,13 @@ "postgres": "^3.4.9", "sharp": "^0.35.5", "undici": "^6.29.0", + "web-push": "^3.6.7", "zod": "^4.6.5" }, "devDependencies": { "@types/node": "^26.6.4", "@types/nodemailer": "^8.0.2", + "@types/web-push": "^3.6.4", "@types/ws": "^8.18.2", "concurrently": "^10.0.5", "drizzle-kit": "^0.31.11", @@ -806,6 +810,7 @@ "apps/mobile": { "name": "@musicshare/mobile", "version": "1.0.0", + "license": "AGPL-3.0-or-later", "dependencies": { "@expo-google-fonts/space-grotesk": "^0.4.1", "@expo/ui": "~57.0.21", @@ -855,6 +860,7 @@ "apps/web": { "name": "@musicshare/web", "version": "0.0.0", + "license": "AGPL-3.0-or-later", "dependencies": { "@musicshare/shared": "*", "motion": "^14.0.0", @@ -7537,6 +7543,16 @@ "@types/react": "*" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", @@ -8445,6 +8461,18 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -8717,6 +8745,12 @@ "node": ">=0.6" } }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -8810,6 +8844,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -9912,6 +9952,15 @@ "stream-shift": "^1.0.2" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -12386,6 +12435,15 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -13359,6 +13417,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -15040,6 +15119,12 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -15059,7 +15144,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -17269,6 +17353,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sandbox-cli-detector": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/sandbox-cli-detector/-/sandbox-cli-detector-0.2.0.tgz", @@ -19948,6 +20038,25 @@ "defaults": "^1.0.3" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -20316,6 +20425,7 @@ "packages/shared": { "name": "@musicshare/shared", "version": "0.0.0", + "license": "AGPL-3.0-or-later", "dependencies": { "qrcode-generator": "^2.0.4", "zod": "^4.6.5" diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index 3eda13f..eb0a3f5 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -12,6 +12,7 @@ import type { } from './dto'; import type { DeviceDTO } from './devices'; import type { LyricsDTO } from './lyrics'; +import type { NotificationListDTO } from './notifications'; import type { ChangeEmailInput, ChangePasswordInput, @@ -21,6 +22,8 @@ import type { ResetPasswordInput, SetFeaturedInput, UpdateMeInput, + PushSubscriptionInput, + SendSongInput, } from './schemas'; export class ApiError extends Error { @@ -135,6 +138,15 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt searchTracks: (query: string) => request(`/tracks/search?q=${q(query)}`), /** Null when no lyrics were found for the song. */ lyrics: (trackId: string) => request(`/tracks/${q(trackId)}/lyrics`), + /** Send a song to people who follow you; returns who it went to. */ + sendSong: (input: SendSongInput) => request<{ sentTo: string[] }>('/shares', { method: 'POST', body: input }), + notifications: (cursor?: string | null) => + request(`/notifications${cursor ? `?cursor=${q(cursor)}` : ''}`), + markNotificationsRead: () => request<{ ok: true }>('/notifications/read', { method: 'POST', body: {} }), + /** The server's public key for push subscriptions (VAPID). */ + pushKey: () => request<{ publicKey: string }>('/push/key'), + addPushSubscription: (input: PushSubscriptionInput) => request<{ ok: true }>('/me/push', { method: 'PUT', body: input }), + removePushSubscription: (endpoint: string) => request<{ ok: true }>('/me/push', { method: 'DELETE', body: { endpoint } }), connections: () => request('/connections'), authorizeUrl: (provider: 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) => diff --git a/packages/shared/src/dto.ts b/packages/shared/src/dto.ts index 1ece400..a9e4471 100644 --- a/packages/shared/src/dto.ts +++ b/packages/shared/src/dto.ts @@ -1,4 +1,5 @@ import type { Entitlements, Plan } from './plans'; +import type { NotificationPrefs } from './notifications'; import type { NowPlayingProvider, StreamingService } from './services'; export interface TrackDTO { @@ -28,6 +29,7 @@ export interface MeDTO extends UserSummaryDTO { preferredService: StreamingService | null; /** The services they use; null until chosen, meaning all of them. */ services: StreamingService[] | null; + notifyPrefs: NotificationPrefs; role: 'user' | 'admin'; /** When onboarding was finished; null means show it. */ onboardedAt: string | null; @@ -161,4 +163,7 @@ export interface TrackSearchResultDTO { } /** Messages pushed over the realtime websocket. */ -export type RealtimeMessage = { type: 'now_playing'; data: NowPlayingDTO }; +export type RealtimeMessage = + | { type: 'now_playing'; data: NowPlayingDTO } + /** Something new in the viewer's notifications. */ + | { type: 'notification'; data: { unread: number } }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 81d9a9a..2ce3db3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -10,3 +10,4 @@ export * from './qr'; export * from './progress'; export * from './reactions'; export * from './lyrics'; +export * from './notifications'; diff --git a/packages/shared/src/notifications.test.ts b/packages/shared/src/notifications.test.ts new file mode 100644 index 0000000..5819908 --- /dev/null +++ b/packages/shared/src/notifications.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { notificationPath, notificationPrefs, notificationText } from './notifications'; + +const actor = { id: 'u1', username: 'bob', displayName: 'Bob', avatarUrl: null }; +const track = { id: 't1', title: 'Teardrop', artist: 'Massive Attack', album: null, artworkUrl: null, durationMs: null, isrc: null, links: {} }; + +describe('notifications', () => { + it('words each kind', () => { + expect(notificationText({ type: 'reaction', actor, track, emoji: '๐Ÿ”ฅ', message: null })).toEqual({ + title: 'Bob reacted ๐Ÿ”ฅ', + body: 'to Teardrop by Massive Attack', + }); + expect(notificationText({ type: 'follow', actor: { ...actor, displayName: null }, track: null, emoji: null, message: null }).title).toBe( + 'bob followed you', + ); + expect(notificationText({ type: 'share', actor, track, emoji: null, message: 'so good' }).body).toBe('Teardrop by Massive Attack: โ€œso goodโ€'); + }); + + it('links songs to the song and follows to the person', () => { + expect(notificationPath({ type: 'share', actor, track })).toBe('/t/t1'); + expect(notificationPath({ type: 'follow', actor, track: null })).toBe('/u/bob'); + }); + + it('treats missing settings as on', () => { + expect(notificationPrefs({ follow: false })).toEqual({ reaction: true, follow: false, share: true }); + }); +}); diff --git a/packages/shared/src/notifications.ts b/packages/shared/src/notifications.ts new file mode 100644 index 0000000..67be48c --- /dev/null +++ b/packages/shared/src/notifications.ts @@ -0,0 +1,61 @@ +import type { TrackDTO, UserSummaryDTO } from './dto'; + +export const NOTIFICATION_TYPES = ['reaction', 'follow', 'share'] as const; +export type NotificationType = (typeof NOTIFICATION_TYPES)[number]; + +/** Which notifications get pushed to your phone/browser. */ +export type NotificationPrefs = Record; + +export const NOTIFICATION_PREF_LABELS: Record = { + reaction: 'Reactions to your songs', + follow: 'New followers', + share: 'Songs sent to you', +}; + +/** Missing settings mean on. */ +export const notificationPrefs = (stored: Partial | null | undefined): NotificationPrefs => ({ + reaction: stored?.reaction ?? true, + follow: stored?.follow ?? true, + share: stored?.share ?? true, +}); + +export interface NotificationDTO { + id: string; + type: NotificationType; + actor: UserSummaryDTO; + track: TrackDTO | null; + emoji: string | null; + message: string | null; + read: boolean; + createdAt: string; +} + +export interface NotificationListDTO { + notifications: NotificationDTO[]; + unread: number; + /** Pass back as ?cursor= for older ones; null at the end. */ + nextCursor: string | null; +} + +/** What a push or a row in the list says. */ +export function notificationText(n: Pick): { + title: string; + body: string; +} { + const who = n.actor.displayName ?? n.actor.username; + const song = n.track ? `${n.track.title} by ${n.track.artist}` : 'a song'; + switch (n.type) { + case 'reaction': + return { title: `${who} reacted ${n.emoji ?? ''}`.trim(), body: `to ${song}` }; + case 'follow': + return { title: `${who} followed you`, body: 'Follow back to see what they play.' }; + case 'share': + return { title: `${who} sent you a song`, body: n.message ? `${song}: โ€œ${n.message}โ€` : song }; + } +} + +/** Where tapping it goes, as an app/web path. */ +export function notificationPath(n: Pick): string { + if ((n.type === 'share' || n.type === 'reaction') && n.track) return `/t/${n.track.id}`; + return `/u/${n.actor.username}`; +} diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 6c7473d..e3982bb 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -52,10 +52,28 @@ export const updateMeSchema = z.object({ .transform((s) => [...new Set(s)]) .nullable() .optional(), + notifyPrefs: z + .object({ reaction: z.boolean(), follow: z.boolean(), share: z.boolean() }) + .partial() + .optional(), /** Mark onboarding as finished. */ onboarded: z.literal(true).optional(), }); +/** Send a song to people who follow you. */ +export const sendSongSchema = z.object({ + trackId: z.uuid(), + to: z.array(z.string().min(1).max(32)).min(1).max(20), + message: z.string().trim().max(280).nullable().optional(), +}); + +/** A browser's or phone's push subscription (both use Web Push encryption keys). */ +export const pushSubscriptionSchema = z.object({ + kind: z.enum(['web', 'unifiedpush']), + endpoint: z.url({ protocol: /^https?$/ }).max(2000), + keys: z.object({ p256dh: z.string().min(20).max(200), auth: z.string().min(8).max(100) }), +}); + export const setFeaturedSchema = z.union([ z.object({ deezerId: z.string().regex(/^\d+$/), comment: z.string().trim().max(280).nullable().optional() }), z.object({ trackId: z.uuid(), comment: z.string().trim().max(280).nullable().optional() }), @@ -72,6 +90,8 @@ export type ChangePasswordInput = z.infer; export type ChangeEmailInput = z.infer; export type UpdateMeInput = z.infer; export type SetFeaturedInput = z.infer; +export type SendSongInput = z.infer; +export type PushSubscriptionInput = z.infer; export const jellyfinConnectSchema = z.object({ serverUrl: z.string().trim().min(1).max(500),