From bab72cf1ad080fda478fcfd02306acbc2e93e92a Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 15:47:29 -0500 Subject: [PATCH 01/12] feat: add reports and user blocks tables --- drizzle/0008_round_falcon.sql | 34 + drizzle/meta/0008_snapshot.json | 1038 +++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/schema.ts | 117 ++++ 4 files changed, 1196 insertions(+) create mode 100644 drizzle/0008_round_falcon.sql create mode 100644 drizzle/meta/0008_snapshot.json diff --git a/drizzle/0008_round_falcon.sql b/drizzle/0008_round_falcon.sql new file mode 100644 index 0000000..12ed028 --- /dev/null +++ b/drizzle/0008_round_falcon.sql @@ -0,0 +1,34 @@ +CREATE TYPE "public"."report_reason" AS ENUM('spam', 'harassment', 'inappropriate', 'impersonation', 'other');--> statement-breakpoint +CREATE TYPE "public"."report_status" AS ENUM('pending', 'reviewed', 'dismissed');--> statement-breakpoint +CREATE TABLE "reports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "reporter_user_id" uuid NOT NULL, + "reported_user_id" uuid NOT NULL, + "reported_profile_id" uuid, + "reason" "report_reason" NOT NULL, + "details" text, + "status" "report_status" DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "reports_details_length_check" CHECK ("reports"."details" is null or char_length("reports"."details") <= 1000), + CONSTRAINT "reports_no_self_report_check" CHECK ("reports"."reporter_user_id" <> "reports"."reported_user_id") +); +--> statement-breakpoint +CREATE TABLE "user_blocks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "blocker_user_id" uuid NOT NULL, + "blocked_user_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_blocks_no_self_block_check" CHECK ("user_blocks"."blocker_user_id" <> "user_blocks"."blocked_user_id") +); +--> statement-breakpoint +ALTER TABLE "reports" ADD CONSTRAINT "reports_reporter_user_id_users_id_fk" FOREIGN KEY ("reporter_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reports" ADD CONSTRAINT "reports_reported_user_id_users_id_fk" FOREIGN KEY ("reported_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reports" ADD CONSTRAINT "reports_reported_profile_id_profiles_id_fk" FOREIGN KEY ("reported_profile_id") REFERENCES "public"."profiles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_blocks" ADD CONSTRAINT "user_blocks_blocker_user_id_users_id_fk" FOREIGN KEY ("blocker_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_blocks" ADD CONSTRAINT "user_blocks_blocked_user_id_users_id_fk" FOREIGN KEY ("blocked_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "reports_reported_user_id_idx" ON "reports" USING btree ("reported_user_id");--> statement-breakpoint +CREATE INDEX "reports_reporter_user_id_idx" ON "reports" USING btree ("reporter_user_id");--> statement-breakpoint +CREATE INDEX "reports_status_idx" ON "reports" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "user_blocks_blocker_blocked_idx" ON "user_blocks" USING btree ("blocker_user_id","blocked_user_id");--> statement-breakpoint +CREATE INDEX "user_blocks_blocker_user_id_idx" ON "user_blocks" USING btree ("blocker_user_id");--> statement-breakpoint +CREATE INDEX "user_blocks_blocked_user_id_idx" ON "user_blocks" USING btree ("blocked_user_id"); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..e05c88e --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1038 @@ +{ + "id": "8091caff-1df7-474b-bd80-c577fc61668e", + "prevId": "9de8daf1-f482-46d9-bd37-06a323a21596", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.profile_target_languages": { + "name": "profile_target_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "profile_target_languages_profile_id_idx": { + "name": "profile_target_languages_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_language_idx": { + "name": "profile_target_languages_language_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_profile_language_idx": { + "name": "profile_target_languages_profile_language_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_profile_position_idx": { + "name": "profile_target_languages_profile_position_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profile_target_languages_profile_id_profiles_id_fk": { + "name": "profile_target_languages_profile_id_profiles_id_fk", + "tableFrom": "profile_target_languages", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profile_target_languages_position_check": { + "name": "profile_target_languages_position_check", + "value": "\"profile_target_languages\".\"position\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "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 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_anonymous_copy": { + "name": "allow_anonymous_copy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_timezone": { + "name": "display_timezone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_availability": { + "name": "display_availability", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "primary_language": { + "name": "primary_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "target_language": { + "name": "target_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "availability": { + "name": "availability", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'flexible'" + }, + "availability_days": { + "name": "availability_days", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "availability_from": { + "name": "availability_from", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "availability_to": { + "name": "availability_to", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "availability_any_time": { + "name": "availability_any_time", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_bumped_at": { + "name": "last_bumped_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "profiles_user_id_idx": { + "name": "profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_public_idx": { + "name": "profiles_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_primary_language_idx": { + "name": "profiles_primary_language_idx", + "columns": [ + { + "expression": "primary_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_target_language_idx": { + "name": "profiles_target_language_idx", + "columns": [ + { + "expression": "target_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_country_idx": { + "name": "profiles_country_idx", + "columns": [ + { + "expression": "country", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_last_bumped_at_idx": { + "name": "profiles_last_bumped_at_idx", + "columns": [ + { + "expression": "last_bumped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profiles_bio_length_check": { + "name": "profiles_bio_length_check", + "value": "char_length(\"profiles\".\"bio\") between 10 and 500" + }, + "profiles_availability_check": { + "name": "profiles_availability_check", + "value": "\"profiles\".\"availability\" in ('weeknights', 'weekends', 'weekday_mornings', 'flexible')" + }, + "profiles_availability_days_check": { + "name": "profiles_availability_days_check", + "value": "\"profiles\".\"availability_days\" is null or \"profiles\".\"availability_days\" in ('any', 'weekdays', 'weekends')" + }, + "profiles_availability_from_check": { + "name": "profiles_availability_from_check", + "value": "\"profiles\".\"availability_from\" is null or \"profiles\".\"availability_from\" ~ '^[0-2][0-9]:[0-5][0-9]$'" + }, + "profiles_availability_to_check": { + "name": "profiles_availability_to_check", + "value": "\"profiles\".\"availability_to\" is null or \"profiles\".\"availability_to\" ~ '^[0-2][0-9]:[0-5][0-9]$'" + }, + "profiles_tags_limit_check": { + "name": "profiles_tags_limit_check", + "value": "cardinality(\"profiles\".\"tags\") <= 8" + } + }, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_user_id": { + "name": "reporter_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reported_user_id": { + "name": "reported_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reported_profile_id": { + "name": "reported_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_reported_user_id_idx": { + "name": "reports_reported_user_id_idx", + "columns": [ + { + "expression": "reported_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_reporter_user_id_idx": { + "name": "reports_reporter_user_id_idx", + "columns": [ + { + "expression": "reporter_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_user_id_users_id_fk": { + "name": "reports_reporter_user_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": ["reporter_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_reported_user_id_users_id_fk": { + "name": "reports_reported_user_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": ["reported_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_reported_profile_id_profiles_id_fk": { + "name": "reports_reported_profile_id_profiles_id_fk", + "tableFrom": "reports", + "tableTo": "profiles", + "columnsFrom": ["reported_profile_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reports_details_length_check": { + "name": "reports_details_length_check", + "value": "\"reports\".\"details\" is null or char_length(\"reports\".\"details\") <= 1000" + }, + "reports_no_self_report_check": { + "name": "reports_no_self_report_check", + "value": "\"reports\".\"reporter_user_id\" <> \"reports\".\"reported_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.saved_profiles": { + "name": "saved_profiles", + "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 + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saved_profiles_user_profile_idx": { + "name": "saved_profiles_user_profile_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_profiles_user_id_idx": { + "name": "saved_profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_profiles_profile_id_idx": { + "name": "saved_profiles_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_profiles_user_id_users_id_fk": { + "name": "saved_profiles_user_id_users_id_fk", + "tableFrom": "saved_profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_profiles_profile_id_profiles_id_fk": { + "name": "saved_profiles_profile_id_profiles_id_fk", + "tableFrom": "saved_profiles", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_blocks": { + "name": "user_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blocker_user_id": { + "name": "blocker_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_user_id": { + "name": "blocked_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_blocks_blocker_blocked_idx": { + "name": "user_blocks_blocker_blocked_idx", + "columns": [ + { + "expression": "blocker_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blocked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_blocks_blocker_user_id_idx": { + "name": "user_blocks_blocker_user_id_idx", + "columns": [ + { + "expression": "blocker_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_blocks_blocked_user_id_idx": { + "name": "user_blocks_blocked_user_id_idx", + "columns": [ + { + "expression": "blocked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_blocks_blocker_user_id_users_id_fk": { + "name": "user_blocks_blocker_user_id_users_id_fk", + "tableFrom": "user_blocks", + "tableTo": "users", + "columnsFrom": ["blocker_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_blocks_blocked_user_id_users_id_fk": { + "name": "user_blocks_blocked_user_id_users_id_fk", + "tableFrom": "user_blocks", + "tableTo": "users", + "columnsFrom": ["blocked_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_blocks_no_self_block_check": { + "name": "user_blocks_no_self_block_check", + "value": "\"user_blocks\".\"blocker_user_id\" <> \"user_blocks\".\"blocked_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "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 + }, + "theme": { + "name": "theme", + "type": "theme", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dark'" + }, + "application_language": { + "name": "application_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "time_format": { + "name": "time_format", + "type": "time_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'24hr'" + }, + "activity_status": { + "name": "activity_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_notifications": { + "name": "push_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "match_alert": { + "name": "match_alert", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "profile_interaction_alert": { + "name": "profile_interaction_alert", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "profile_view_alert": { + "name": "profile_view_alert", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_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": { + "user_settings_user_id_idx": { + "name": "user_settings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_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": { + "users_discord_user_id_idx": { + "name": "users_discord_user_id_idx", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.proficiency_level": { + "name": "proficiency_level", + "schema": "public", + "values": ["beginner", "intermediate", "advanced", "native-level"] + }, + "public.report_reason": { + "name": "report_reason", + "schema": "public", + "values": [ + "spam", + "harassment", + "inappropriate", + "impersonation", + "other" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": ["pending", "reviewed", "dismissed"] + }, + "public.theme": { + "name": "theme", + "schema": "public", + "values": ["dark", "light"] + }, + "public.time_format": { + "name": "time_format", + "schema": "public", + "values": ["12hr", "24hr"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 17d917e..3df4da0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1782114038962, "tag": "0007_closed_roland_deschain", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1782144862573, + "tag": "0008_round_falcon", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index 1697f97..6ca1d43 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -24,6 +24,20 @@ export const themeEnum = pgEnum('theme', ['dark', 'light']); export const timeFormatEnum = pgEnum('time_format', ['12hr', '24hr']); +export const reportReasonEnum = pgEnum('report_reason', [ + 'spam', + 'harassment', + 'inappropriate', + 'impersonation', + 'other', +]); + +export const reportStatusEnum = pgEnum('report_status', [ + 'pending', + 'reviewed', + 'dismissed', +]); + export const users = pgTable( 'users', { @@ -193,10 +207,78 @@ export const userSettings = pgTable( (table) => [uniqueIndex('user_settings_user_id_idx').on(table.userId)], ); +export const reports = pgTable( + 'reports', + { + id: uuid('id').defaultRandom().primaryKey(), + reporterUserId: uuid('reporter_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + reportedUserId: uuid('reported_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + reportedProfileId: uuid('reported_profile_id').references( + () => profiles.id, + { onDelete: 'set null' }, + ), + reason: reportReasonEnum('reason').notNull(), + details: text('details'), + status: reportStatusEnum('status').default('pending').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index('reports_reported_user_id_idx').on(table.reportedUserId), + index('reports_reporter_user_id_idx').on(table.reporterUserId), + index('reports_status_idx').on(table.status), + check( + 'reports_details_length_check', + sql`${table.details} is null or char_length(${table.details}) <= 1000`, + ), + check( + 'reports_no_self_report_check', + sql`${table.reporterUserId} <> ${table.reportedUserId}`, + ), + ], +); + +export const userBlocks = pgTable( + 'user_blocks', + { + id: uuid('id').defaultRandom().primaryKey(), + blockerUserId: uuid('blocker_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + blockedUserId: uuid('blocked_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex('user_blocks_blocker_blocked_idx').on( + table.blockerUserId, + table.blockedUserId, + ), + index('user_blocks_blocker_user_id_idx').on(table.blockerUserId), + index('user_blocks_blocked_user_id_idx').on(table.blockedUserId), + check( + 'user_blocks_no_self_block_check', + sql`${table.blockerUserId} <> ${table.blockedUserId}`, + ), + ], +); + export const usersRelations = relations(users, ({ many, one }) => ({ profile: one(profiles), savedProfiles: many(savedProfiles), settings: one(userSettings), + reportsFiled: many(reports, { relationName: 'reporter' }), + reportsReceived: many(reports, { relationName: 'reported' }), + blocksCreated: many(userBlocks, { relationName: 'blocker' }), + blocksReceived: many(userBlocks, { relationName: 'blocked' }), })); export const profilesRelations = relations(profiles, ({ many, one }) => ({ @@ -236,6 +318,36 @@ export const userSettingsRelations = relations(userSettings, ({ one }) => ({ }), })); +export const reportsRelations = relations(reports, ({ one }) => ({ + reporter: one(users, { + fields: [reports.reporterUserId], + references: [users.id], + relationName: 'reporter', + }), + reported: one(users, { + fields: [reports.reportedUserId], + references: [users.id], + relationName: 'reported', + }), + profile: one(profiles, { + fields: [reports.reportedProfileId], + references: [profiles.id], + }), +})); + +export const userBlocksRelations = relations(userBlocks, ({ one }) => ({ + blocker: one(users, { + fields: [userBlocks.blockerUserId], + references: [users.id], + relationName: 'blocker', + }), + blocked: one(users, { + fields: [userBlocks.blockedUserId], + references: [users.id], + relationName: 'blocked', + }), +})); + export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Profile = typeof profiles.$inferSelect; @@ -247,3 +359,8 @@ export type SavedProfile = typeof savedProfiles.$inferSelect; export type NewSavedProfile = typeof savedProfiles.$inferInsert; export type UserSettings = typeof userSettings.$inferSelect; export type NewUserSettings = typeof userSettings.$inferInsert; +export type Report = typeof reports.$inferSelect; +export type NewReport = typeof reports.$inferInsert; +export type ReportReason = (typeof reportReasonEnum.enumValues)[number]; +export type UserBlock = typeof userBlocks.$inferSelect; +export type NewUserBlock = typeof userBlocks.$inferInsert; From 812e7169ca126f719ed09e80ff32a871cbd76e21 Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 15:47:38 -0500 Subject: [PATCH 02/12] feat: add safety db helpers for reports and blocks --- src/db/index.ts | 1 + src/db/safety.ts | 103 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/db/safety.ts diff --git a/src/db/index.ts b/src/db/index.ts index 71f4cbd..a1af942 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,5 +1,6 @@ export * from './account'; export * from './profiles'; +export * from './safety'; export * from './saved'; export * from './schema'; export * from './settings'; diff --git a/src/db/safety.ts b/src/db/safety.ts new file mode 100644 index 0000000..a6fcd11 --- /dev/null +++ b/src/db/safety.ts @@ -0,0 +1,103 @@ +import 'server-only'; + +import { and, eq } from 'drizzle-orm'; +import { db } from './client'; +import { + type NewReport, + type ReportReason, + reports, + userBlocks, +} from './schema'; + +const missingSafetyStorageCodes = new Set(['42P01', '42703', '42704']); + +const getErrorCode = (error: unknown) => + error && typeof error === 'object' && 'code' in error + ? String(error.code) + : null; + +const getErrorCause = (error: unknown) => + error && typeof error === 'object' && 'cause' in error ? error.cause : null; + +const isMissingSafetyStorageError = (error: unknown): boolean => { + let current: unknown = error; + + while (current) { + const code = getErrorCode(current); + + if (code && missingSafetyStorageCodes.has(code)) { + return true; + } + + current = getErrorCause(current); + } + + return false; +}; + +export type CreateReportInput = { + reporterUserId: string; + reportedUserId: string; + reportedProfileId?: string | null; + reason: ReportReason; + details?: string | null; +}; + +export const createReport = async (input: CreateReportInput) => { + const values: NewReport = { + reporterUserId: input.reporterUserId, + reportedUserId: input.reportedUserId, + reportedProfileId: input.reportedProfileId ?? null, + reason: input.reason, + details: input.details?.trim() ? input.details.trim() : null, + }; + + const [report] = await db.insert(reports).values(values).returning(); + + return report; +}; + +export const blockUser = async ( + blockerUserId: string, + blockedUserId: string, +) => { + await db + .insert(userBlocks) + .values({ blockerUserId, blockedUserId }) + .onConflictDoNothing({ + target: [userBlocks.blockerUserId, userBlocks.blockedUserId], + }); +}; + +export const unblockUser = async ( + blockerUserId: string, + blockedUserId: string, +) => { + await db + .delete(userBlocks) + .where( + and( + eq(userBlocks.blockerUserId, blockerUserId), + eq(userBlocks.blockedUserId, blockedUserId), + ), + ); +}; + +export const listBlockedUserIds = async ( + blockerUserId: string, +): Promise => { + try { + const rows = await db + .select({ blockedUserId: userBlocks.blockedUserId }) + .from(userBlocks) + .where(eq(userBlocks.blockerUserId, blockerUserId)); + + return rows.map((row) => row.blockedUserId); + } catch (error) { + if (isMissingSafetyStorageError(error)) { + return []; + } + + throw error; + } +}; From 857f8382a8838fef60e9659f2ebe172732a859bf Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 15:47:40 -0500 Subject: [PATCH 03/12] feat: hide blocked users from discovery feed --- src/app/[lang]/DiscoveryFeed.tsx | 4 +++- src/app/[lang]/page.tsx | 4 ++++ src/db/profiles.ts | 16 +++++++++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/[lang]/DiscoveryFeed.tsx b/src/app/[lang]/DiscoveryFeed.tsx index 5c4e21c..7b4070a 100644 --- a/src/app/[lang]/DiscoveryFeed.tsx +++ b/src/app/[lang]/DiscoveryFeed.tsx @@ -9,6 +9,7 @@ type DiscoveryFeedProps = { locale: string; needsOnboarding: boolean; savedProfileIds: string[]; + blockedUserIds: string[]; currentProfileId?: string; bumpReadyAt?: string; viewerTimezone?: string; @@ -22,6 +23,7 @@ export const DiscoveryFeed = async ({ locale, needsOnboarding, savedProfileIds, + blockedUserIds, currentProfileId, bumpReadyAt, viewerTimezone, @@ -32,7 +34,7 @@ export const DiscoveryFeed = async ({ let feedError = false; try { - profiles = await listPublicProfiles(); + profiles = await listPublicProfiles({ blockedUserIds }); } catch (error) { console.error('Failed to load public profiles:', error); feedError = true; diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index ed6bef0..9ec4794 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -2,6 +2,7 @@ import { Suspense } from 'react'; import type { AvailabilityPattern } from '@/constants/availability'; import { getProfileByUserId, + listBlockedUserIds, listSavedProfileIds, toViewerAvailabilityContext, upsertDiscordUser, @@ -24,6 +25,7 @@ export default async function Home({ const user = await getCurrentUser(); let needsOnboarding = false; let savedProfileIds: string[] = []; + let blockedUserIds: string[] = []; let currentProfileId: string | undefined; let bumpReadyAt: string | undefined; let viewerTimezone: string | undefined; @@ -35,6 +37,7 @@ export default async function Home({ needsOnboarding = !profile; currentProfileId = profile?.profile.id; savedProfileIds = await listSavedProfileIds(persistedUser.id); + blockedUserIds = await listBlockedUserIds(persistedUser.id); if (profile) { const viewer = toViewerAvailabilityContext(profile.profile); @@ -76,6 +79,7 @@ export default async function Home({ locale={lang} needsOnboarding={needsOnboarding} savedProfileIds={savedProfileIds} + blockedUserIds={blockedUserIds} currentProfileId={currentProfileId} bumpReadyAt={bumpReadyAt} viewerTimezone={viewerTimezone} diff --git a/src/db/profiles.ts b/src/db/profiles.ts index 475d8b1..b8045dd 100644 --- a/src/db/profiles.ts +++ b/src/db/profiles.ts @@ -1,6 +1,6 @@ import 'server-only'; -import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, notInArray, sql } from 'drizzle-orm'; import { type AvailabilityPattern, availabilityPatternToPreset, @@ -279,7 +279,17 @@ export const getPublicProfileById = async (profileId: string) => { return row; }; -export const listPublicProfiles = async () => { +export const listPublicProfiles = async ( + options: { blockedUserIds?: string[] } = {}, +) => { + const { blockedUserIds = [] } = options; + const visibility = blockedUserIds.length + ? and( + eq(profiles.isPublic, true), + notInArray(profiles.userId, blockedUserIds), + ) + : eq(profiles.isPublic, true); + const rows = await db .select({ profile: profiles, @@ -287,7 +297,7 @@ export const listPublicProfiles = async () => { }) .from(profiles) .innerJoin(users, eq(profiles.userId, users.id)) - .where(eq(profiles.isPublic, true)) + .where(visibility) .orderBy( sql`${profiles.lastBumpedAt} desc nulls last`, desc(profiles.updatedAt), From 33e023f8742733e3ad8248e668b647c5003f1196 Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 15:47:42 -0500 Subject: [PATCH 04/12] feat: add report and block api routes --- src/app/api/block/route.ts | 87 ++++++++++++++++++++++++++++++++ src/app/api/report/route.ts | 99 +++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/app/api/block/route.ts create mode 100644 src/app/api/report/route.ts diff --git a/src/app/api/block/route.ts b/src/app/api/block/route.ts new file mode 100644 index 0000000..dbc54fd --- /dev/null +++ b/src/app/api/block/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server'; +import { + blockUser, + getProfileById, + getUserByDiscordId, + unblockUser, + upsertDiscordUser, +} from '@/db'; +import { getCurrentUser } from '@/lib/auth'; + +const readProfileId = async (request: Request): Promise => { + let body: unknown; + + try { + body = await request.json(); + } catch { + return null; + } + + if ( + !body || + typeof body !== 'object' || + !('profileId' in body) || + typeof body.profileId !== 'string' || + body.profileId.length === 0 + ) { + return null; + } + + return body.profileId; +}; + +export const POST = async (request: Request) => { + const currentUser = await getCurrentUser(); + + if (!currentUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const profileId = await readProfileId(request); + + if (!profileId) { + return NextResponse.json({ error: 'Invalid profileId' }, { status: 400 }); + } + + const target = await getProfileById(profileId); + + if (!target) { + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }); + } + + const blocker = await upsertDiscordUser(currentUser); + + if (target.profile.userId === blocker.id) { + return NextResponse.json( + { error: 'Cannot block yourself' }, + { status: 400 }, + ); + } + + await blockUser(blocker.id, target.profile.userId); + + return NextResponse.json({ blocked: true }); +}; + +export const DELETE = async (request: Request) => { + const currentUser = await getCurrentUser(); + + if (!currentUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const profileId = await readProfileId(request); + + if (!profileId) { + return NextResponse.json({ error: 'Invalid profileId' }, { status: 400 }); + } + + const target = await getProfileById(profileId); + const blocker = await getUserByDiscordId(currentUser.id); + + if (target && blocker) { + await unblockUser(blocker.id, target.profile.userId); + } + + return NextResponse.json({ blocked: false }); +}; diff --git a/src/app/api/report/route.ts b/src/app/api/report/route.ts new file mode 100644 index 0000000..05e88af --- /dev/null +++ b/src/app/api/report/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from 'next/server'; +import { + createReport, + getProfileById, + type ReportReason, + upsertDiscordUser, +} from '@/db'; +import { getCurrentUser } from '@/lib/auth'; + +const REPORT_REASONS: ReportReason[] = [ + 'spam', + 'harassment', + 'inappropriate', + 'impersonation', + 'other', +]; + +const MAX_DETAILS_LENGTH = 1000; + +type ReportBody = { + profileId: string; + reason: ReportReason; + details?: string; +}; + +const readReportBody = async (request: Request): Promise => { + let body: unknown; + + try { + body = await request.json(); + } catch { + return null; + } + + if (!body || typeof body !== 'object') { + return null; + } + + const { profileId, reason, details } = body as Record; + + if (typeof profileId !== 'string' || profileId.length === 0) { + return null; + } + + if ( + typeof reason !== 'string' || + !REPORT_REASONS.includes(reason as ReportReason) + ) { + return null; + } + + if ( + details !== undefined && + (typeof details !== 'string' || details.length > MAX_DETAILS_LENGTH) + ) { + return null; + } + + return { profileId, reason: reason as ReportReason, details }; +}; + +export const POST = async (request: Request) => { + const currentUser = await getCurrentUser(); + + if (!currentUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await readReportBody(request); + + if (!body) { + return NextResponse.json({ error: 'Invalid report' }, { status: 400 }); + } + + const target = await getProfileById(body.profileId); + + if (!target?.profile.isPublic) { + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }); + } + + const reporter = await upsertDiscordUser(currentUser); + + if (target.profile.userId === reporter.id) { + return NextResponse.json( + { error: 'Cannot report your own profile' }, + { status: 400 }, + ); + } + + await createReport({ + reporterUserId: reporter.id, + reportedUserId: target.profile.userId, + reportedProfileId: target.profile.id, + reason: body.reason, + details: body.details, + }); + + return NextResponse.json({ reported: true }); +}; From 1a34ccaf1313517cd038da1c975ea5cf65bc208d Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 15:47:43 -0500 Subject: [PATCH 05/12] feat: add report and block client requests --- src/features/Discovery/safetyRequests.ts | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/features/Discovery/safetyRequests.ts diff --git a/src/features/Discovery/safetyRequests.ts b/src/features/Discovery/safetyRequests.ts new file mode 100644 index 0000000..73c9500 --- /dev/null +++ b/src/features/Discovery/safetyRequests.ts @@ -0,0 +1,37 @@ +export type ReportReason = + | 'spam' + | 'harassment' + | 'inappropriate' + | 'impersonation' + | 'other'; + +export const reportProfileRequest = async ( + profileId: string, + reason: ReportReason, + details?: string, +) => { + const response = await fetch('/api/report', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profileId, reason, details }), + }); + + if (!response.ok) { + throw new Error('Report submission failed'); + } +}; + +export const blockProfileRequest = async ( + profileId: string, + nextBlocked: boolean, +) => { + const response = await fetch('/api/block', { + method: nextBlocked ? 'POST' : 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profileId }), + }); + + if (!response.ok) { + throw new Error('Block update failed'); + } +}; From cea038b460e2ba4d7911a091a416444c555f88f5 Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 15:47:53 -0500 Subject: [PATCH 06/12] refactor: allow react node toast descriptions --- src/hooks/useToast.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts index 30c264d..ed9dcca 100644 --- a/src/hooks/useToast.ts +++ b/src/hooks/useToast.ts @@ -1,4 +1,5 @@ import { + type ReactNode, type RefObject, useCallback, useEffect, @@ -11,7 +12,7 @@ const ANIMATION_DURATION = 300; export type ToastData = { id: number; title: string; - description: string; + description: ReactNode; duration?: number; iconUrl?: string; }; From fc791cb29333d64b3059e2ede5f418ee9587461f Mon Sep 17 00:00:00 2001 From: chev Date: Mon, 22 Jun 2026 15:47:55 -0500 Subject: [PATCH 07/12] feat: add profile report dialog --- src/features/Discovery/ReportDialog.tsx | 159 ++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/features/Discovery/ReportDialog.tsx diff --git a/src/features/Discovery/ReportDialog.tsx b/src/features/Discovery/ReportDialog.tsx new file mode 100644 index 0000000..a820572 --- /dev/null +++ b/src/features/Discovery/ReportDialog.tsx @@ -0,0 +1,159 @@ +'use client'; + +import * as Dialog from '@radix-ui/react-dialog'; +import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; +import { MdClose } from 'react-icons/md'; +import { Button } from '@/components/Button'; +import type { ReportReason } from './safetyRequests'; + +const REPORT_REASONS: ReportReason[] = [ + 'spam', + 'harassment', + 'inappropriate', + 'impersonation', + 'other', +]; + +const reasonLabelKeys: Record = { + spam: 'reportReasonSpam', + harassment: 'reportReasonHarassment', + inappropriate: 'reportReasonInappropriate', + impersonation: 'reportReasonImpersonation', + other: 'reportReasonOther', +}; + +const MAX_DETAILS_LENGTH = 1000; + +type ReportDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + profileName?: string; + onSubmit: (reason: ReportReason, details: string) => Promise | void; +}; + +export const ReportDialog = ({ + open, + onOpenChange, + profileName, + onSubmit, +}: ReportDialogProps) => { + const t = useTranslations('Discovery'); + const [reason, setReason] = useState(null); + const [details, setDetails] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (!open) { + setReason(null); + setDetails(''); + setIsSubmitting(false); + } + }, [open]); + + const handleSubmit = async () => { + if (!reason || isSubmitting) { + return; + } + + setIsSubmitting(true); + + try { + await onSubmit(reason, details.trim()); + onOpenChange(false); + } catch { + setIsSubmitting(false); + return; + } + + setIsSubmitting(false); + }; + + return ( + + + + event.preventDefault()} + > +
+
+ + {t('reportDialogTitle')} + + + {profileName + ? t('reportDialogDescriptionNamed', { name: profileName }) + : t('reportDialogDescription')} + +
+ + + +
+ +
+ + {t('reportReasonLegend')} + + {REPORT_REASONS.map((value) => { + const isSelected = reason === value; + + return ( + + ); + })} +
+ +