diff --git a/drizzle/0009_ambitious_landau.sql b/drizzle/0009_ambitious_landau.sql new file mode 100644 index 0000000..672be66 --- /dev/null +++ b/drizzle/0009_ambitious_landau.sql @@ -0,0 +1,15 @@ +CREATE TYPE "public"."notification_kind" AS ENUM('copy', 'view');--> statement-breakpoint +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "kind" "notification_kind" NOT NULL, + "actor_name" text, + "actor_avatar_url" text, + "is_guest" boolean DEFAULT false NOT NULL, + "read" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() 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 +CREATE INDEX "notifications_user_id_created_at_idx" ON "notifications" USING btree ("user_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "notifications_user_id_read_idx" ON "notifications" USING btree ("user_id","read"); \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..749b9aa --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1162 @@ +{ + "id": "6454c92e-0129-462d-9b18-6215355f0216", + "prevId": "8091caff-1df7-474b-bd80-c577fc61668e", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + }, + "kind": { + "name": "kind", + "type": "notification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_avatar_url": { + "name": "actor_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_guest": { + "name": "is_guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_id_created_at_idx": { + "name": "notifications_user_id_created_at_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": {} + }, + "notifications_user_id_read_idx": { + "name": "notifications_user_id_read_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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.notification_kind": { + "name": "notification_kind", + "schema": "public", + "values": ["copy", "view"] + }, + "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 3df4da0..e38106a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1782144862573, "tag": "0008_round_falcon", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1782833918261, + "tag": "0009_ambitious_landau", + "breakpoints": true } ] } diff --git a/src/app/[lang]/u/[id]/PublicProfileClient.tsx b/src/app/[lang]/u/[id]/PublicProfileClient.tsx index 143ad76..7730aaf 100644 --- a/src/app/[lang]/u/[id]/PublicProfileClient.tsx +++ b/src/app/[lang]/u/[id]/PublicProfileClient.tsx @@ -20,6 +20,7 @@ import { buildPublicProfileUrl, shareProfileUrl, } from '@/features/Discovery/shareProfile'; +import { notifyUsernameCopied } from '@/features/Inbox/notificationRequests'; import { Navbar } from '@/features/Navbar'; import { useRouteProgressRouter } from '@/features/Navigation/RouteProgress'; import { useToastStack } from '@/hooks/useToast'; @@ -187,6 +188,13 @@ export const PublicProfileClient = ({ isSaved={isSaved} viewerTimezone={viewerTimezone} onToggleSave={canSave ? handleToggleSave : undefined} + onCopyUsername={ + isLoggedIn + ? (_username, profileId) => { + notifyUsernameCopied(profileId).catch(() => {}); + } + : undefined + } onShare={handleShare} onReport={isOwnProfile ? undefined : handleReport} onBlock={isOwnProfile ? undefined : handleBlock} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..4f6c241 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,149 @@ +import { NextResponse } from 'next/server'; +import { + clearNotifications, + createNotification, + deleteNotification, + getProfileById, + getUserByDiscordId, + listNotificationsForUser, + markAllNotificationsRead, + setNotificationRead, + upsertDiscordUser, +} from '@/db'; +import { getCurrentUser } from '@/lib/auth'; + +const parseBody = async ( + request: Request, +): Promise> => { + try { + const body = await request.json(); + return body && typeof body === 'object' + ? (body as Record) + : {}; + } catch { + return {}; + } +}; + +export const GET = async () => { + const currentUser = await getCurrentUser(); + + if (!currentUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await getUserByDiscordId(currentUser.id); + + if (!user) { + return NextResponse.json({ notifications: [] }); + } + + const rows = await listNotificationsForUser(user.id); + + return NextResponse.json({ + notifications: rows.map((row) => ({ + id: row.id, + kind: row.kind, + actorName: row.actorName ?? undefined, + actorAvatarUrl: row.actorAvatarUrl ?? undefined, + isGuest: row.isGuest, + read: row.read, + createdAt: row.createdAt.toISOString(), + })), + }); +}; + +export const POST = async (request: Request) => { + const currentUser = await getCurrentUser(); + + if (!currentUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await parseBody(request); + const profileId = body.profileId; + + if (typeof profileId !== 'string' || profileId.length === 0) { + return NextResponse.json({ error: 'Invalid profileId' }, { status: 400 }); + } + + const target = await getProfileById(profileId); + + if (!target?.profile.isPublic) { + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }); + } + + const actor = await upsertDiscordUser(currentUser); + + if (target.profile.userId === actor.id) { + return NextResponse.json({ created: false }); + } + + await createNotification({ + userId: target.profile.userId, + kind: 'copy', + actorName: actor.displayName, + actorAvatarUrl: actor.avatarUrl, + isGuest: false, + }); + + return NextResponse.json({ created: true }); +}; + +export const PATCH = async (request: Request) => { + const currentUser = await getCurrentUser(); + + if (!currentUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await getUserByDiscordId(currentUser.id); + + if (!user) { + return NextResponse.json({ ok: true }); + } + + const body = await parseBody(request); + + if (body.all === true) { + await markAllNotificationsRead(user.id); + return NextResponse.json({ ok: true }); + } + + if (typeof body.id !== 'string' || typeof body.read !== 'boolean') { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } + + await setNotificationRead(user.id, body.id, body.read); + + return NextResponse.json({ ok: true }); +}; + +export const DELETE = async (request: Request) => { + const currentUser = await getCurrentUser(); + + if (!currentUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await getUserByDiscordId(currentUser.id); + + if (!user) { + return NextResponse.json({ ok: true }); + } + + const body = await parseBody(request); + + if (body.all === true) { + await clearNotifications(user.id); + return NextResponse.json({ ok: true }); + } + + if (typeof body.id !== 'string' || body.id.length === 0) { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } + + await deleteNotification(user.id, body.id); + + return NextResponse.json({ ok: true }); +}; diff --git a/src/db/index.ts b/src/db/index.ts index a1af942..4d51ef4 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,4 +1,5 @@ export * from './account'; +export * from './notifications'; export * from './profiles'; export * from './safety'; export * from './saved'; diff --git a/src/db/notifications.ts b/src/db/notifications.ts new file mode 100644 index 0000000..8e873f5 --- /dev/null +++ b/src/db/notifications.ts @@ -0,0 +1,100 @@ +import 'server-only'; + +import { and, desc, eq } from 'drizzle-orm'; +import { db } from './client'; +import { + type NewNotificationRecord, + type NotificationRecord, + notifications, +} from './schema'; + +const missingNotificationsStorageCodes = new Set(['42P01', '42703']); + +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 isMissingNotificationsStorageError = (error: unknown): boolean => { + let current: unknown = error; + + while (current) { + const code = getErrorCode(current); + + if (code && missingNotificationsStorageCodes.has(code)) { + return true; + } + + current = getErrorCause(current); + } + + return false; +}; + +export const listNotificationsForUser = async ( + userId: string, +): Promise => { + try { + return await db + .select() + .from(notifications) + .where(eq(notifications.userId, userId)) + .orderBy(desc(notifications.createdAt)); + } catch (error) { + if (isMissingNotificationsStorageError(error)) { + return []; + } + + throw error; + } +}; + +export const createNotification = async (values: NewNotificationRecord) => { + const [created] = await db.insert(notifications).values(values).returning(); + + return created; +}; + +export const setNotificationRead = async ( + userId: string, + notificationId: string, + read: boolean, +) => { + await db + .update(notifications) + .set({ read }) + .where( + and( + eq(notifications.id, notificationId), + eq(notifications.userId, userId), + ), + ); +}; + +export const markAllNotificationsRead = async (userId: string) => { + await db + .update(notifications) + .set({ read: true }) + .where(eq(notifications.userId, userId)); +}; + +export const deleteNotification = async ( + userId: string, + notificationId: string, +) => { + await db + .delete(notifications) + .where( + and( + eq(notifications.id, notificationId), + eq(notifications.userId, userId), + ), + ); +}; + +export const clearNotifications = async (userId: string) => { + await db.delete(notifications).where(eq(notifications.userId, userId)); +}; diff --git a/src/db/schema.ts b/src/db/schema.ts index 6ca1d43..6e157ee 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -24,6 +24,11 @@ export const themeEnum = pgEnum('theme', ['dark', 'light']); export const timeFormatEnum = pgEnum('time_format', ['12hr', '24hr']); +export const notificationKindEnum = pgEnum('notification_kind', [ + 'copy', + 'view', +]); + export const reportReasonEnum = pgEnum('report_reason', [ 'spam', 'harassment', @@ -207,6 +212,31 @@ export const userSettings = pgTable( (table) => [uniqueIndex('user_settings_user_id_idx').on(table.userId)], ); +export const notifications = pgTable( + 'notifications', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + kind: notificationKindEnum('kind').notNull(), + actorName: text('actor_name'), + actorAvatarUrl: text('actor_avatar_url'), + isGuest: boolean('is_guest').default(false).notNull(), + read: boolean('read').default(false).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index('notifications_user_id_created_at_idx').on( + table.userId, + table.createdAt.desc(), + ), + index('notifications_user_id_read_idx').on(table.userId, table.read), + ], +); + export const reports = pgTable( 'reports', { @@ -275,6 +305,7 @@ export const usersRelations = relations(users, ({ many, one }) => ({ profile: one(profiles), savedProfiles: many(savedProfiles), settings: one(userSettings), + notifications: many(notifications), reportsFiled: many(reports, { relationName: 'reporter' }), reportsReceived: many(reports, { relationName: 'reported' }), blocksCreated: many(userBlocks, { relationName: 'blocker' }), @@ -318,6 +349,13 @@ export const userSettingsRelations = relations(userSettings, ({ one }) => ({ }), })); +export const notificationsRelations = relations(notifications, ({ one }) => ({ + user: one(users, { + fields: [notifications.userId], + references: [users.id], + }), +})); + export const reportsRelations = relations(reports, ({ one }) => ({ reporter: one(users, { fields: [reports.reporterUserId], @@ -359,6 +397,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 NotificationRecord = typeof notifications.$inferSelect; +export type NewNotificationRecord = typeof notifications.$inferInsert; export type Report = typeof reports.$inferSelect; export type NewReport = typeof reports.$inferInsert; export type ReportReason = (typeof reportReasonEnum.enumValues)[number]; diff --git a/src/features/Discovery/DiscoveryPage.tsx b/src/features/Discovery/DiscoveryPage.tsx index 61180db..1f595a4 100644 --- a/src/features/Discovery/DiscoveryPage.tsx +++ b/src/features/Discovery/DiscoveryPage.tsx @@ -7,6 +7,7 @@ import { MdClose } from 'react-icons/md'; import { FilterBar } from '@/components/Filter'; import { ToastStack } from '@/components/Toast'; import type { AvailabilityPattern } from '@/constants/availability'; +import { notifyUsernameCopied } from '@/features/Inbox/notificationRequests'; import { Navbar } from '@/features/Navbar'; import { useRouteProgressRouter } from '@/features/Navigation/RouteProgress'; import { @@ -631,6 +632,13 @@ export const DiscoveryPage = ({ currentProfileId={currentProfileId} viewerTimezone={viewerTimezone} onSaveProfile={saveProfileRequest} + onCopyUsername={ + isLoggedIn + ? (_username, profileId) => { + notifyUsernameCopied(profileId).catch(() => {}); + } + : undefined + } onViewProfile={handleViewProfile} onShare={handleShareProfile} onReport={handleReportProfile} diff --git a/src/features/Inbox/Inbox.stories.tsx b/src/features/Inbox/Inbox.stories.tsx index 7de930e..9af7ef5 100644 --- a/src/features/Inbox/Inbox.stories.tsx +++ b/src/features/Inbox/Inbox.stories.tsx @@ -28,6 +28,7 @@ const FreeNotificationsStory = () => { const t = useTranslations('Inbox'); return ( { return ( { const canvas = within(canvasElement); @@ -232,7 +235,7 @@ const LiveUpdateStory = () => {
- +
diff --git a/src/features/Inbox/Inbox.tsx b/src/features/Inbox/Inbox.tsx index 704f46b..bc39209 100644 --- a/src/features/Inbox/Inbox.tsx +++ b/src/features/Inbox/Inbox.tsx @@ -8,29 +8,71 @@ import { MdOutlineKeyboardArrowRight, } from 'react-icons/md'; import { Button } from '@/components/Button'; -import type { Notifications } from '@/types'; +import type { Notification, Notifications } from '@/types'; import { NotificationEntry } from './NotificationEntry'; +import { + clearNotificationsRequest, + deleteNotificationRequest, + fetchNotifications, + markAllNotificationsReadRequest, + setNotificationReadRequest, +} from './notificationRequests'; + +type InboxNotification = Notification & { + read: boolean; + isDeleting?: boolean; +}; -const initializeNotifications = (initial: Notifications, premium: boolean) => - initial - .filter((notification) => premium || notification.kind === 'copy') - .map((n) => ({ ...n, read: false, isDeleting: false })); +const visibleForPremium = ( + items: T[], + premium: boolean, +) => items.filter((item) => premium || item.kind === 'copy'); + +const initializeControlled = ( + initial: Notifications, + premium: boolean, +): InboxNotification[] => + visibleForPremium(initial, premium).map((n) => ({ + ...n, + read: false, + isDeleting: false, + })); const notificationsSignature = (initial: Notifications, premium: boolean) => `${premium}:${initial.map((n) => n.id).join(',')}`; +const formatRelativeTime = ( + createdAt: string, + t: ReturnType>, +) => { + const minutes = Math.max( + 0, + Math.floor((Date.now() - new Date(createdAt).getTime()) / 60000), + ); + + if (minutes < 1) return t('justNow'); + if (minutes < 60) return t('minutesAgo', { count: minutes }); + + const hours = Math.floor(minutes / 60); + if (hours < 24) return t('hoursAgo', { count: hours }); + + return t('daysAgo', { count: Math.floor(hours / 24) }); +}; + export const Inbox = ({ notifications: initialNotifications, premium = false, + persist = true, }: { notifications: Notifications; premium?: boolean; + persist?: boolean; }) => { const t = useTranslations('Inbox'); const locale = useLocale(); - const [notifications, setNotifications] = useState(() => - initializeNotifications(initialNotifications, premium), + const [notifications, setNotifications] = useState(() => + persist ? [] : initializeControlled(initialNotifications, premium), ); const [currentPage, setCurrentPage] = useState(1); const [isMounted, setIsMounted] = useState(false); @@ -43,11 +85,41 @@ export const Inbox = ({ setIsMounted(true); }, []); - const nextSignature = notificationsSignature(initialNotifications, premium); - if (nextSignature !== signature) { - setSignature(nextSignature); - setNotifications(initializeNotifications(initialNotifications, premium)); - setCurrentPage(1); + useEffect(() => { + if (!persist) return; + + let active = true; + + fetchNotifications().then((stored) => { + if (!active) return; + + setNotifications( + visibleForPremium(stored, premium).map((n) => ({ + id: n.id, + kind: n.kind, + actorName: n.actorName, + actorAvatarUrl: n.actorAvatarUrl, + isGuest: n.isGuest, + createdAt: n.createdAt, + read: n.read, + isDeleting: false, + })), + ); + setCurrentPage(1); + }); + + return () => { + active = false; + }; + }, [persist, premium]); + + if (!persist) { + const nextSignature = notificationsSignature(initialNotifications, premium); + if (nextSignature !== signature) { + setSignature(nextSignature); + setNotifications(initializeControlled(initialNotifications, premium)); + setCurrentPage(1); + } } const unreadCount = notifications.filter((n) => !n.read).length; @@ -58,9 +130,17 @@ export const Inbox = ({ const currentNotifications = notifications.slice(startIndex, endIndex); const handleMarkAsRead = (id: string) => { + const target = notifications.find((n) => n.id === id); + if (!target) return; + + const nextRead = !target.read; setNotifications((prev) => - prev.map((n) => (n.id === id ? { ...n, read: !n.read } : n)), + prev.map((n) => (n.id === id ? { ...n, read: nextRead } : n)), ); + + if (persist) { + setNotificationReadRequest(id, nextRead).catch(() => {}); + } }; const handleDelete = (id: string) => { @@ -70,10 +150,18 @@ export const Inbox = ({ setTimeout(() => { setNotifications((prev) => prev.filter((n) => n.id !== id)); }, 400); // Matched to slideOutRight animation duration + + if (persist) { + deleteNotificationRequest(id).catch(() => {}); + } }; const handleMarkAllAsRead = () => { setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + + if (persist) { + markAllNotificationsReadRequest().catch(() => {}); + } }; const handleClearAll = () => { @@ -94,6 +182,10 @@ export const Inbox = ({ setNotifications([]); setCurrentPage(1); }, totalDuration); + + if (persist) { + clearNotificationsRequest().catch(() => {}); + } }; const handleNextPage = () => { @@ -174,7 +266,12 @@ export const Inbox = ({ {currentNotifications.length > 0 ? ( currentNotifications.map((notification, index) => ( handleMarkAsRead(notification.id)} diff --git a/src/features/Inbox/notificationRequests.ts b/src/features/Inbox/notificationRequests.ts new file mode 100644 index 0000000..9305219 --- /dev/null +++ b/src/features/Inbox/notificationRequests.ts @@ -0,0 +1,58 @@ +import type { NotificationKind } from '@/types'; + +export type StoredNotification = { + id: string; + kind: NotificationKind; + actorName?: string; + actorAvatarUrl?: string; + isGuest?: boolean; + read: boolean; + createdAt: string; +}; + +const ENDPOINT = '/api/notifications'; + +const jsonRequest = async (method: string, body: unknown) => { + const response = await fetch(ENDPOINT, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Notification request failed: ${method}`); + } +}; + +export const fetchNotifications = async (): Promise => { + try { + const response = await fetch(ENDPOINT); + + if (!response.ok) { + return []; + } + + const data = (await response.json()) as { + notifications?: StoredNotification[]; + }; + + return data.notifications ?? []; + } catch { + return []; + } +}; + +export const setNotificationReadRequest = (id: string, read: boolean) => + jsonRequest('PATCH', { id, read }); + +export const markAllNotificationsReadRequest = () => + jsonRequest('PATCH', { all: true }); + +export const deleteNotificationRequest = (id: string) => + jsonRequest('DELETE', { id }); + +export const clearNotificationsRequest = () => + jsonRequest('DELETE', { all: true }); + +export const notifyUsernameCopied = (profileId: string) => + jsonRequest('POST', { profileId }); diff --git a/src/features/Navbar/Navbar.stories.tsx b/src/features/Navbar/Navbar.stories.tsx index ceaf87f..25c828f 100644 --- a/src/features/Navbar/Navbar.stories.tsx +++ b/src/features/Navbar/Navbar.stories.tsx @@ -38,6 +38,7 @@ export const LoggedIn: Story = { iconUrl={MOCK_USER_AVATAR_URL} isLoggedIn premium + persistNotifications={false} notifications={[ { id: '1', @@ -82,6 +83,7 @@ export const LoggedOut: Story = { iconUrl={undefined} isLoggedIn={false} notifications={[]} + persistNotifications={false} /> ); }, diff --git a/src/features/Navbar/Navbar.tsx b/src/features/Navbar/Navbar.tsx index 02dad1d..a51bdc6 100644 --- a/src/features/Navbar/Navbar.tsx +++ b/src/features/Navbar/Navbar.tsx @@ -11,6 +11,7 @@ type NavbarProps = { iconUrl?: string; isLoggedIn: boolean; notifications: Notifications; + persistNotifications?: boolean; premium?: boolean; onHomeClick?: () => void; onLoginClick: () => void; @@ -28,6 +29,7 @@ export const Navbar: React.FC = ({ onHomeClick, onLoginClick, notifications, + persistNotifications = true, premium = false, onProfileClick, onBumpProfileClick, @@ -63,7 +65,11 @@ export const Navbar: React.FC = ({ {isLoggedIn ? ( <> - +