diff --git a/.env.example b/.env.example index bfbd0a2..70c83d0 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,7 @@ DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= DISCORD_REDIRECT_URI=http://localhost:3000/api/auth/discord/callback DATABASE_URL= +# Comma-separated Discord user IDs granted access to the admin analytics dashboard +POLYCORD_ADMIN_USER_IDS= +# Set to "true" to disable all product analytics tracking +POLYCORD_ANALYTICS_DISABLED= diff --git a/drizzle/0010_curvy_shard.sql b/drizzle/0010_curvy_shard.sql new file mode 100644 index 0000000..07f336a --- /dev/null +++ b/drizzle/0010_curvy_shard.sql @@ -0,0 +1,15 @@ +CREATE TABLE "analytics_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(64) NOT NULL, + "user_id" uuid, + "anonymous_id" varchar(64), + "locale" varchar(16), + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_settings" ADD COLUMN "product_analytics" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "analytics_events" ADD CONSTRAINT "analytics_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "analytics_events_name_idx" ON "analytics_events" USING btree ("name");--> statement-breakpoint +CREATE INDEX "analytics_events_created_at_idx" ON "analytics_events" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "analytics_events_user_id_idx" ON "analytics_events" USING btree ("user_id"); \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..c11e26c --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1282 @@ +{ + "id": "66847fa2-934e-4d42-88b2-8a1bf4fa7ca6", + "prevId": "6454c92e-0129-462d-9b18-6215355f0216", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.analytics_events": { + "name": "analytics_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "anonymous_id": { + "name": "anonymous_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "analytics_events_name_idx": { + "name": "analytics_events_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_events_created_at_idx": { + "name": "analytics_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analytics_events_user_id_idx": { + "name": "analytics_events_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analytics_events_user_id_users_id_fk": { + "name": "analytics_events_user_id_users_id_fk", + "tableFrom": "analytics_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "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 + }, + "product_analytics": { + "name": "product_analytics", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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 e38106a..0fde61d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1782833918261, "tag": "0009_ambitious_landau", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1782835712871, + "tag": "0010_curvy_shard", + "breakpoints": true } ] } diff --git a/src/app/[lang]/analytics/page.tsx b/src/app/[lang]/analytics/page.tsx new file mode 100644 index 0000000..1c9ff09 --- /dev/null +++ b/src/app/[lang]/analytics/page.tsx @@ -0,0 +1,54 @@ +import { redirect } from 'next/navigation'; +import { + getAnalyticsEventCounts, + getAnalyticsTotals, + getDailyAnalyticsTotals, + getRecentAnalyticsEvents, +} from '@/db'; +import { AnalyticsDashboard } from '@/features/Analytics/AnalyticsDashboard'; +import { isAdmin } from '@/lib/admin'; +import { getCurrentUser } from '@/lib/auth'; + +const RANGE_DAYS = 30; +const DAILY_DAYS = 14; + +export default async function AnalyticsRoute({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const user = await getCurrentUser(); + + if (!user || !isAdmin(user)) { + redirect(`/${lang}`); + } + + const [totals, eventCounts, daily, recent] = await Promise.all([ + getAnalyticsTotals(RANGE_DAYS), + getAnalyticsEventCounts(RANGE_DAYS), + getDailyAnalyticsTotals(DAILY_DAYS), + getRecentAnalyticsEvents(25), + ]); + + return ( +
+ ({ + id: event.id, + name: event.name, + userId: event.userId, + locale: event.locale, + metadata: event.metadata, + createdAt: event.createdAt.toISOString(), + }))} + /> +
+ ); +} diff --git a/src/app/[lang]/onboarding/page.tsx b/src/app/[lang]/onboarding/page.tsx index 8990c38..51f6a1f 100644 --- a/src/app/[lang]/onboarding/page.tsx +++ b/src/app/[lang]/onboarding/page.tsx @@ -1,6 +1,8 @@ import { redirect } from 'next/navigation'; import { getProfileByUserId, upsertDiscordUser } from '@/db'; import { OnboardingPage } from '@/features/Onboarding'; +import { ANALYTICS_EVENTS } from '@/lib/analytics/events'; +import { trackEvent } from '@/lib/analytics/track.server'; import { getCurrentUser } from '@/lib/auth'; export default async function OnboardingRoute({ @@ -22,6 +24,12 @@ export default async function OnboardingRoute({ redirect(`/${lang}`); } + await trackEvent({ + name: ANALYTICS_EVENTS.onboardingStart, + userId: user.id, + locale: lang, + }); + return ( new NextResponse(null, { status: 204 }); + +export const POST = async (request: Request) => { + let body: unknown; + + try { + body = await request.json(); + } catch { + return noContent(); + } + + if (!body || typeof body !== 'object') { + return noContent(); + } + + const { name, locale, metadata } = body as Record; + + if (typeof name !== 'string' || !isClientAnalyticsEvent(name)) { + return noContent(); + } + + const currentUser = await getCurrentUser(); + let userId: string | null = null; + + if (currentUser) { + const user = await getUserByDiscordId(currentUser.id); + userId = user?.id ?? null; + } + + await trackEvent({ + name, + userId, + locale: typeof locale === 'string' ? localeFromPath(`/${locale}`) : null, + metadata: + metadata && typeof metadata === 'object' + ? (metadata as Record) + : undefined, + }); + + return noContent(); +}; diff --git a/src/app/api/auth/discord/callback/route.ts b/src/app/api/auth/discord/callback/route.ts index ba602e5..a850e00 100644 --- a/src/app/api/auth/discord/callback/route.ts +++ b/src/app/api/auth/discord/callback/route.ts @@ -1,5 +1,8 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { upsertDiscordUser } from '@/db'; +import { getUserByDiscordId, upsertDiscordUser } from '@/db'; +import { ANALYTICS_EVENTS } from '@/lib/analytics/events'; +import { localeFromPath } from '@/lib/analytics/locale'; +import { trackEvent } from '@/lib/analytics/track.server'; import { AUTH_ERROR_PARAM, clearOAuthStateCookie, @@ -110,7 +113,17 @@ export const GET = async (request: NextRequest) => { } const currentUser = normalizeDiscordUser(discordUser); - await upsertDiscordUser(currentUser); + const existingUser = await getUserByDiscordId(currentUser.id); + const user = await upsertDiscordUser(currentUser); + + await trackEvent({ + name: existingUser + ? ANALYTICS_EVENTS.authLogin + : ANALYTICS_EVENTS.authSignup, + userId: user.id, + locale: localeFromPath(redirectTo), + metadata: { isNewUser: !existingUser }, + }); const response = NextResponse.redirect( new URL(redirectTo, request.nextUrl.origin), diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 8b56aaf..b606985 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -6,6 +6,9 @@ import { upsertProfileForUser, } from '@/db'; import { onboardingSchema } from '@/features/Onboarding/schema'; +import { ANALYTICS_EVENTS } from '@/lib/analytics/events'; +import { localeFromRequest } from '@/lib/analytics/locale'; +import { trackEvent } from '@/lib/analytics/track.server'; import { getCurrentUser } from '@/lib/auth'; export const POST = async (request: Request) => { @@ -58,5 +61,17 @@ export const POST = async (request: Request) => { timezone: values.timezone, }); + await trackEvent({ + name: ANALYTICS_EVENTS.onboardingComplete, + userId: user.id, + locale: localeFromRequest(request), + metadata: { + primaryLanguage: values.primaryLanguage, + targetLanguage: values.targetLanguage, + proficiencyLevel: values.proficiencyLevel, + tagCount: (values.tags ?? []).length, + }, + }); + return NextResponse.json({ profileId: profile.id }); }; diff --git a/src/app/api/profile/bump/route.ts b/src/app/api/profile/bump/route.ts index 269ab18..3aa35e4 100644 --- a/src/app/api/profile/bump/route.ts +++ b/src/app/api/profile/bump/route.ts @@ -5,10 +5,13 @@ import { upsertDiscordUser, } from '@/db'; import { getBumpCooldown } from '@/features/Profile/bumpProfile'; +import { ANALYTICS_EVENTS } from '@/lib/analytics/events'; +import { localeFromRequest } from '@/lib/analytics/locale'; +import { trackEvent } from '@/lib/analytics/track.server'; import { getCurrentUser } from '@/lib/auth'; import { hasPremiumEntitlement } from '@/lib/entitlements'; -export const POST = async () => { +export const POST = async (request: Request) => { const currentUser = await getCurrentUser(); if (!currentUser) { @@ -45,6 +48,13 @@ export const POST = async () => { const bumpedAt = new Date(); await bumpProfileForUser(user.id, bumpedAt); + await trackEvent({ + name: ANALYTICS_EVENTS.profileBump, + userId: user.id, + locale: localeFromRequest(request), + metadata: { premium }, + }); + return NextResponse.json({ lastBumpedAt: bumpedAt.toISOString(), nextBumpAt: getBumpCooldown( diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index 2fc576a..4fba0f6 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -7,6 +7,9 @@ import { upsertProfileForUser, } from '@/db'; import { FREE_LANGUAGE_CAP, profileSchema } from '@/features/Profile/schema'; +import { ANALYTICS_EVENTS } from '@/lib/analytics/events'; +import { localeFromRequest } from '@/lib/analytics/locale'; +import { trackEvent } from '@/lib/analytics/track.server'; import { getCurrentUser } from '@/lib/auth'; export const POST = async (request: Request) => { @@ -70,6 +73,17 @@ export const POST = async (request: Request) => { timezone: values.timezone || null, }); + await trackEvent({ + name: ANALYTICS_EVENTS.profileSave, + userId: user.id, + locale: localeFromRequest(request), + metadata: { + isPublic: values.isPublic, + targetLanguageCount: values.targetLanguages.length, + tagCount: (values.tags ?? []).length, + }, + }); + return NextResponse.json({ profileId: profile.id }); }; diff --git a/src/app/api/saved/route.ts b/src/app/api/saved/route.ts index 1c5ed0b..af5965b 100644 --- a/src/app/api/saved/route.ts +++ b/src/app/api/saved/route.ts @@ -6,6 +6,9 @@ import { unsaveProfile, upsertDiscordUser, } from '@/db'; +import { ANALYTICS_EVENTS } from '@/lib/analytics/events'; +import { localeFromRequest } from '@/lib/analytics/locale'; +import { trackEvent } from '@/lib/analytics/track.server'; import { getCurrentUser } from '@/lib/auth'; const readProfileId = async (request: Request): Promise => { @@ -60,6 +63,12 @@ export const POST = async (request: Request) => { await saveProfile(user.id, profileId); + await trackEvent({ + name: ANALYTICS_EVENTS.profileSaveFavorite, + userId: user.id, + locale: localeFromRequest(request), + }); + return NextResponse.json({ saved: true }); }; diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 256e678..a51dc2d 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -50,6 +50,7 @@ export const POST = async (request: Request) => { matchAlert: values.matchAlert, profileInteractionAlert: values.profileInteractionAlert, profileViewAlert: values.profileViewAlert, + productAnalytics: values.productAnalytics, }); return NextResponse.json({ saved: true }); diff --git a/src/db/analytics.ts b/src/db/analytics.ts new file mode 100644 index 0000000..f27a338 --- /dev/null +++ b/src/db/analytics.ts @@ -0,0 +1,69 @@ +import 'server-only'; + +import { count, countDistinct, desc, gte, sql } from 'drizzle-orm'; +import { db } from './client'; +import { analyticsEvents, type NewAnalyticsEvent } from './schema'; + +const daysAgo = (days: number) => + new Date(Date.now() - days * 24 * 60 * 60 * 1000); + +export const insertAnalyticsEvent = async (event: NewAnalyticsEvent) => { + await db.insert(analyticsEvents).values(event); +}; + +export const getAnalyticsTotals = async (sinceDays = 30) => { + const [row] = await db + .select({ + totalEvents: count(), + uniqueUsers: countDistinct(analyticsEvents.userId), + }) + .from(analyticsEvents) + .where(gte(analyticsEvents.createdAt, daysAgo(sinceDays))); + + return { + totalEvents: row?.totalEvents ?? 0, + uniqueUsers: row?.uniqueUsers ?? 0, + }; +}; + +export const getAnalyticsEventCounts = async (sinceDays = 30) => { + const rows = await db + .select({ name: analyticsEvents.name, total: count() }) + .from(analyticsEvents) + .where(gte(analyticsEvents.createdAt, daysAgo(sinceDays))) + .groupBy(analyticsEvents.name) + .orderBy(desc(count())); + + return rows; +}; + +export const getRecentAnalyticsEvents = async (limit = 25) => { + return db + .select({ + id: analyticsEvents.id, + name: analyticsEvents.name, + userId: analyticsEvents.userId, + locale: analyticsEvents.locale, + metadata: analyticsEvents.metadata, + createdAt: analyticsEvents.createdAt, + }) + .from(analyticsEvents) + .orderBy(desc(analyticsEvents.createdAt)) + .limit(limit); +}; + +export const getDailyAnalyticsTotals = async (sinceDays = 14) => { + const day = sql`date_trunc('day', ${analyticsEvents.createdAt})`; + + const rows = await db + .select({ day, total: count() }) + .from(analyticsEvents) + .where(gte(analyticsEvents.createdAt, daysAgo(sinceDays))) + .groupBy(day) + .orderBy(day); + + return rows.map((row) => ({ + date: new Date(row.day).toISOString().slice(0, 10), + total: row.total, + })); +}; diff --git a/src/db/index.ts b/src/db/index.ts index 4d51ef4..e192367 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,4 +1,5 @@ export * from './account'; +export * from './analytics'; export * from './notifications'; export * from './profiles'; export * from './safety'; diff --git a/src/db/schema.ts b/src/db/schema.ts index 6e157ee..0ad7abb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -4,6 +4,7 @@ import { check, index, integer, + jsonb, pgEnum, pgTable, text, @@ -202,6 +203,7 @@ export const userSettings = pgTable( .default(true) .notNull(), profileViewAlert: boolean('profile_view_alert').default(false).notNull(), + productAnalytics: boolean('product_analytics').default(true).notNull(), createdAt: timestamp('created_at', { withTimezone: true }) .defaultNow() .notNull(), @@ -212,6 +214,28 @@ export const userSettings = pgTable( (table) => [uniqueIndex('user_settings_user_id_idx').on(table.userId)], ); +export const analyticsEvents = pgTable( + 'analytics_events', + { + id: uuid('id').defaultRandom().primaryKey(), + name: varchar('name', { length: 64 }).notNull(), + userId: uuid('user_id').references(() => users.id, { + onDelete: 'set null', + }), + anonymousId: varchar('anonymous_id', { length: 64 }), + locale: varchar('locale', { length: 16 }), + metadata: jsonb('metadata'), + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index('analytics_events_name_idx').on(table.name), + index('analytics_events_created_at_idx').on(table.createdAt), + index('analytics_events_user_id_idx').on(table.userId), + ], +); + export const notifications = pgTable( 'notifications', { @@ -397,6 +421,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 AnalyticsEvent = typeof analyticsEvents.$inferSelect; +export type NewAnalyticsEvent = typeof analyticsEvents.$inferInsert; export type NotificationRecord = typeof notifications.$inferSelect; export type NewNotificationRecord = typeof notifications.$inferInsert; export type Report = typeof reports.$inferSelect; diff --git a/src/db/settings.ts b/src/db/settings.ts index 7fa980a..5358aff 100644 --- a/src/db/settings.ts +++ b/src/db/settings.ts @@ -21,6 +21,7 @@ export type UserSettingsValues = Pick< | 'matchAlert' | 'profileInteractionAlert' | 'profileViewAlert' + | 'productAnalytics' >; export type ProfilePrivacyValues = Pick< diff --git a/src/features/Analytics/AnalyticsDashboard.stories.tsx b/src/features/Analytics/AnalyticsDashboard.stories.tsx new file mode 100644 index 0000000..0558acc --- /dev/null +++ b/src/features/Analytics/AnalyticsDashboard.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, within } from '@storybook/test'; +import { AnalyticsDashboard } from './AnalyticsDashboard'; + +const daily = Array.from({ length: 14 }, (_, index) => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - (13 - index)); + return { + date: date.toISOString().slice(0, 10), + total: [18, 24, 31, 27, 40, 52, 48, 61, 55, 70, 66, 81, 74, 92][index], + }; +}); + +const recent = [ + { name: 'discovery.view', locale: 'en', userId: 'u1' }, + { name: 'profile.view', locale: 'ja', userId: 'u2' }, + { name: 'profile.username_copy', locale: 'en', userId: null }, + { name: 'auth.signup', locale: 'en', userId: 'u3' }, + { name: 'profile.save', locale: 'ja', userId: 'u2' }, + { name: 'onboarding.complete', locale: 'en', userId: 'u3' }, +].map((event, index) => ({ + id: `event-${index}`, + name: event.name, + userId: event.userId, + locale: event.locale, + metadata: null, + createdAt: new Date(Date.now() - index * 1000 * 60 * 17).toISOString(), +})); + +const meta: Meta = { + title: 'Features/Analytics/AnalyticsDashboard', + component: AnalyticsDashboard, + parameters: { layout: 'fullscreen' }, + args: { + locale: 'en', + rangeDays: 30, + dailyDays: 14, + totals: { totalEvents: 1280, uniqueUsers: 143 }, + eventCounts: [ + { name: 'discovery.view', total: 540 }, + { name: 'profile.view', total: 212 }, + { name: 'auth.login', total: 184 }, + { name: 'auth.signup', total: 88 }, + { name: 'onboarding.start', total: 84 }, + { name: 'onboarding.complete', total: 72 }, + { name: 'profile.username_copy', total: 64 }, + { name: 'profile.save', total: 58 }, + { name: 'profile.bump', total: 41 }, + { name: 'profile.save_favorite', total: 30 }, + ], + daily, + recent, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText('Activation funnel')).toBeInTheDocument(); + await expect(canvas.getByText('1,280')).toBeInTheDocument(); + await expect(canvas.getAllByText('Discovery views').length).toBeGreaterThan( + 0, + ); + }, +}; + +export const Empty: Story = { + args: { + totals: { totalEvents: 0, uniqueUsers: 0 }, + eventCounts: [], + daily: [], + recent: [], + }, +}; diff --git a/src/features/Analytics/AnalyticsDashboard.tsx b/src/features/Analytics/AnalyticsDashboard.tsx new file mode 100644 index 0000000..35c6e38 --- /dev/null +++ b/src/features/Analytics/AnalyticsDashboard.tsx @@ -0,0 +1,274 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useMemo } from 'react'; +import { ACTIVATION_FUNNEL } from '@/lib/analytics/events'; + +const DAILY_CHART_HEIGHT = 152; + +const EVENT_LABEL_KEYS: Record = { + 'auth.signup': 'eventAuthSignup', + 'auth.login': 'eventAuthLogin', + 'onboarding.start': 'eventOnboardingStart', + 'onboarding.complete': 'eventOnboardingComplete', + 'profile.save': 'eventProfileSave', + 'profile.bump': 'eventProfileBump', + 'discovery.view': 'eventDiscoveryView', + 'profile.view': 'eventProfileView', + 'profile.username_copy': 'eventUsernameCopy', + 'profile.save_favorite': 'eventSaveFavorite', + 'connection.intro_request': 'eventIntroRequest', + 'safety.report': 'eventReport', + 'safety.block': 'eventBlock', + 'premium.upgrade': 'eventPremiumUpgrade', +}; + +export type AnalyticsDashboardProps = { + locale: string; + rangeDays: number; + dailyDays: number; + totals: { totalEvents: number; uniqueUsers: number }; + eventCounts: { name: string; total: number }[]; + daily: { date: string; total: number }[]; + recent: { + id: string; + name: string; + userId: string | null; + locale: string | null; + metadata: unknown; + createdAt: string; + }[]; +}; + +const StatCard = ({ label, value }: { label: string; value: string }) => ( +
+ + {label} + + + {value} + +
+); + +export const AnalyticsDashboard = ({ + locale, + rangeDays, + dailyDays, + totals, + eventCounts, + daily, + recent, +}: AnalyticsDashboardProps) => { + const t = useTranslations('Analytics'); + + const formatNumber = (value: number) => value.toLocaleString(locale); + const eventLabel = (name: string) => { + const key = EVENT_LABEL_KEYS[name]; + return key ? t(key) : name; + }; + + const countByName = useMemo( + () => new Map(eventCounts.map((event) => [event.name, event.total])), + [eventCounts], + ); + + const funnel = useMemo( + () => + ACTIVATION_FUNNEL.map((name) => ({ + name, + total: countByName.get(name) ?? 0, + })), + [countByName], + ); + + const funnelTop = funnel[0]?.total ?? 0; + const maxEventCount = Math.max(...eventCounts.map((e) => e.total), 1); + + const days = useMemo(() => { + const dailyMap = new Map(daily.map((entry) => [entry.date, entry.total])); + return Array.from({ length: dailyDays }, (_, index) => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - (dailyDays - 1 - index)); + const key = date.toISOString().slice(0, 10); + return { date: key, total: dailyMap.get(key) ?? 0 }; + }); + }, [daily, dailyDays]); + + const maxDaily = Math.max(...days.map((day) => day.total), 1); + + const formatTime = (iso: string) => + new Date(iso).toLocaleString(locale, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + return ( +
+
+

+ {t('title')} +

+

+ {t('subtitle', { days: rangeDays })} +

+
+ +
+ + + +
+ + {totals.totalEvents === 0 ? ( +
+

+ {t('emptyTitle')} +

+

+ {t('emptyDescription')} +

+
+ ) : ( +
+
+

+ {t('funnelTitle')} +

+

+ {t('funnelDescription')} +

+
+ {funnel.map((step) => { + const share = funnelTop > 0 ? step.total / funnelTop : 0; + return ( +
+
+ + {eventLabel(step.name)} + + + {formatNumber(step.total)} + + {`${Math.round(share * 100)}%`} + + +
+
+
+
+
+ ); + })} +
+
+ +
+

+ {t('dailyTitle', { days: dailyDays })} +

+
+
+ {days.map((day) => ( +
+ ))} +
+
+ {days.map((day) => ( + + {day.date.slice(8)} + + ))} +
+
+
+ +
+

+ {t('eventVolumeTitle')} +

+
+ {eventCounts.map((event) => ( +
+ + {eventLabel(event.name)} + +
+
+
+ + {formatNumber(event.total)} + +
+ ))} +
+
+ +
+

+ {t('recentTitle')} +

+
+ {recent.map((event) => ( +
+ + {eventLabel(event.name)} + + + {event.userId + ? t('audienceAuthenticated') + : t('audienceAnonymous')} + + + {event.locale ?? '—'} + + + {formatTime(event.createdAt)} + +
+ ))} +
+
+
+ )} +
+ ); +}; diff --git a/src/features/Discovery/ProfileCard.tsx b/src/features/Discovery/ProfileCard.tsx index 4de588a..c8f50b3 100644 --- a/src/features/Discovery/ProfileCard.tsx +++ b/src/features/Discovery/ProfileCard.tsx @@ -28,6 +28,8 @@ import { type Proficiency, type TimeFormat, } from '@/constants/languages'; +import { trackClientEvent } from '@/lib/analytics/client'; +import { ANALYTICS_EVENTS } from '@/lib/analytics/events'; import { AvailabilityRow } from './AvailabilityRow'; import { type CardTheme, @@ -186,6 +188,7 @@ export const ProfileCard = ({ try { await navigator.clipboard.writeText(profile.discordUsername); setCopied(true); + trackClientEvent(ANALYTICS_EVENTS.profileUsernameCopy); if (onCopyUsername) { onCopyUsername(profile.discordUsername, profile.id, profile.avatarUrl); diff --git a/src/features/Settings/SettingsPage.stories.tsx b/src/features/Settings/SettingsPage.stories.tsx index a20e76d..7932a05 100644 --- a/src/features/Settings/SettingsPage.stories.tsx +++ b/src/features/Settings/SettingsPage.stories.tsx @@ -11,6 +11,7 @@ const defaultSettings: SettingsFormValues = { matchAlert: true, profileInteractionAlert: true, profileViewAlert: true, + productAnalytics: true, theme: 'dark', applicationLanguage: 'en', timeFormat: '24hr', @@ -82,6 +83,23 @@ export const SwitchTabs: Story = { }, }; +export const PrivacyTab: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + fireEvent.click(canvas.getByRole('button', { name: 'Privacy' })); + + await waitFor(() => + expect(canvas.getByText('Product analytics')).toBeInTheDocument(), + ); + await expect( + canvas.getByText( + 'Help improve Polycord by sharing anonymous, non-personal usage events.', + ), + ).toBeInTheDocument(); + }, +}; + export const EmailValidation: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index 5bd7a04..a915269 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -636,6 +636,22 @@ export const SettingsPage: React.FC = ({ )} /> + + + ( + + )} + /> + )} diff --git a/src/features/Settings/schema.ts b/src/features/Settings/schema.ts index 397773d..9d3930b 100644 --- a/src/features/Settings/schema.ts +++ b/src/features/Settings/schema.ts @@ -9,6 +9,7 @@ export const settingsSchema = z.object({ matchAlert: z.boolean(), profileInteractionAlert: z.boolean(), profileViewAlert: z.boolean(), + productAnalytics: z.boolean(), theme: z.enum(['dark', 'light']), applicationLanguage: z.string().min(1), timeFormat: z.enum(['12hr', '24hr']), diff --git a/src/lib/admin.ts b/src/lib/admin.ts new file mode 100644 index 0000000..d2b7ac5 --- /dev/null +++ b/src/lib/admin.ts @@ -0,0 +1,8 @@ +import type { CurrentUser } from './auth'; + +export const isAdmin = (user: CurrentUser) => + (process.env.POLYCORD_ADMIN_USER_IDS ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + .includes(user.id); diff --git a/src/lib/analytics/client.ts b/src/lib/analytics/client.ts new file mode 100644 index 0000000..4a088f3 --- /dev/null +++ b/src/lib/analytics/client.ts @@ -0,0 +1,33 @@ +import type { AnalyticsEventName } from './events'; + +export const trackClientEvent = ( + name: AnalyticsEventName, + metadata?: Record, +) => { + if (typeof window === 'undefined') { + return; + } + + try { + const body = JSON.stringify({ + name, + locale: document.documentElement.lang || undefined, + metadata, + }); + + if (typeof navigator.sendBeacon === 'function') { + navigator.sendBeacon( + '/api/analytics', + new Blob([body], { type: 'application/json' }), + ); + return; + } + + void fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }); + } catch {} +}; diff --git a/src/lib/analytics/events.ts b/src/lib/analytics/events.ts new file mode 100644 index 0000000..ddb71e6 --- /dev/null +++ b/src/lib/analytics/events.ts @@ -0,0 +1,39 @@ +export const ANALYTICS_EVENTS = { + authSignup: 'auth.signup', + authLogin: 'auth.login', + onboardingStart: 'onboarding.start', + onboardingComplete: 'onboarding.complete', + profileSave: 'profile.save', + profileBump: 'profile.bump', + discoveryView: 'discovery.view', + profileView: 'profile.view', + profileUsernameCopy: 'profile.username_copy', + profileSaveFavorite: 'profile.save_favorite', + connectionIntroRequest: 'connection.intro_request', + safetyReport: 'safety.report', + safetyBlock: 'safety.block', + premiumUpgrade: 'premium.upgrade', +} as const; + +export type AnalyticsEventName = + (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS]; + +export const ANALYTICS_EVENT_NAMES = Object.values( + ANALYTICS_EVENTS, +) as AnalyticsEventName[]; + +export const ACTIVATION_FUNNEL: AnalyticsEventName[] = [ + ANALYTICS_EVENTS.authSignup, + ANALYTICS_EVENTS.onboardingStart, + ANALYTICS_EVENTS.onboardingComplete, + ANALYTICS_EVENTS.profileSave, +]; + +const CLIENT_ANALYTICS_EVENTS: AnalyticsEventName[] = [ + ANALYTICS_EVENTS.profileUsernameCopy, +]; + +export const isClientAnalyticsEvent = ( + name: string, +): name is AnalyticsEventName => + (CLIENT_ANALYTICS_EVENTS as string[]).includes(name); diff --git a/src/lib/analytics/locale.ts b/src/lib/analytics/locale.ts new file mode 100644 index 0000000..d23011a --- /dev/null +++ b/src/lib/analytics/locale.ts @@ -0,0 +1,25 @@ +import { locales } from '@/utils/locales'; + +export const localeFromPath = (path?: string | null): string | null => { + if (!path) { + return null; + } + + const segment = path.split('/').filter(Boolean)[0]; + + return locales.includes(segment as (typeof locales)[number]) ? segment : null; +}; + +export const localeFromRequest = (request: Request): string | null => { + const referer = request.headers.get('referer'); + + if (!referer) { + return null; + } + + try { + return localeFromPath(new URL(referer).pathname); + } catch { + return null; + } +}; diff --git a/src/lib/analytics/metadata.ts b/src/lib/analytics/metadata.ts new file mode 100644 index 0000000..657560e --- /dev/null +++ b/src/lib/analytics/metadata.ts @@ -0,0 +1,44 @@ +const ALLOWED_METADATA_KEYS = new Set([ + 'source', + 'result', + 'isNewUser', + 'premium', + 'isPublic', + 'primaryLanguage', + 'targetLanguage', + 'targetLanguageCount', + 'proficiencyLevel', + 'tagCount', + 'availability', + 'country', +]); + +export type AnalyticsMetadata = Record; + +export type SanitizedMetadata = Record; + +export const sanitizeMetadata = ( + metadata?: AnalyticsMetadata | null, +): SanitizedMetadata | null => { + if (!metadata) { + return null; + } + + const clean: SanitizedMetadata = {}; + + for (const [key, value] of Object.entries(metadata)) { + if (!ALLOWED_METADATA_KEYS.has(key)) { + continue; + } + + if (typeof value === 'number' && Number.isFinite(value)) { + clean[key] = value; + } else if (typeof value === 'boolean') { + clean[key] = value; + } else if (typeof value === 'string' && value.length > 0) { + clean[key] = value.slice(0, 64); + } + } + + return Object.keys(clean).length > 0 ? clean : null; +}; diff --git a/src/lib/analytics/track.server.ts b/src/lib/analytics/track.server.ts new file mode 100644 index 0000000..9a361f0 --- /dev/null +++ b/src/lib/analytics/track.server.ts @@ -0,0 +1,48 @@ +import 'server-only'; + +import { getUserSettingsByUserId, insertAnalyticsEvent } from '@/db'; +import type { AnalyticsEventName } from './events'; +import { type AnalyticsMetadata, sanitizeMetadata } from './metadata'; + +const analyticsGloballyEnabled = () => + process.env.POLYCORD_ANALYTICS_DISABLED !== 'true'; + +type TrackEventInput = { + name: AnalyticsEventName; + userId?: string | null; + anonymousId?: string | null; + locale?: string | null; + metadata?: AnalyticsMetadata; +}; + +export const trackEvent = async ({ + name, + userId, + anonymousId, + locale, + metadata, +}: TrackEventInput): Promise => { + try { + if (!analyticsGloballyEnabled()) { + return; + } + + if (userId) { + const settings = await getUserSettingsByUserId(userId); + + if (settings && !settings.productAnalytics) { + return; + } + } + + await insertAnalyticsEvent({ + name, + userId: userId ?? null, + anonymousId: anonymousId ?? null, + locale: locale ?? null, + metadata: sanitizeMetadata(metadata), + }); + } catch (error) { + console.error('analytics trackEvent failed', error); + } +}; diff --git a/src/locales/en.json b/src/locales/en.json index 26c237f..4691e03 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -276,6 +276,8 @@ "allowAnonymousCopyingDescription": "Let users who aren't logged in copy your Discord username.", "displayTimezoneLabel": "Display timezone", "displayTimezoneDescription": "Show your timezone on your public profile.", + "productAnalyticsLabel": "Product analytics", + "productAnalyticsDescription": "Help improve Polycord by sharing anonymous, non-personal usage events.", "appLanguagePlaceholder": "Select app language", "exportDataLabel": "Export account data", "exportDataDescription": "Download your account, profile, and saved privacy settings as JSON.", @@ -293,6 +295,36 @@ "deleteAccountRetentionNote": "Profile and account rows are deleted. Safety, report, moderation, billing, and legal records may be retained when those systems exist.", "deleteAccountError": "Account could not be deleted. Please try again." }, + "Analytics": { + "title": "Product analytics", + "subtitle": "Core product events over the last {days} days.", + "totalEvents": "Total events", + "uniqueUsers": "Signed-in users", + "eventTypes": "Event types", + "emptyTitle": "No events tracked yet", + "emptyDescription": "Events will appear here as users move through the product.", + "funnelTitle": "Activation funnel", + "funnelDescription": "How far users get through the core loop, relative to sign-ups.", + "dailyTitle": "Daily events (last {days} days)", + "eventVolumeTitle": "Event volume", + "recentTitle": "Recent events", + "audienceAuthenticated": "Signed in", + "audienceAnonymous": "Anonymous", + "eventAuthSignup": "Sign-ups", + "eventAuthLogin": "Logins", + "eventOnboardingStart": "Onboarding started", + "eventOnboardingComplete": "Onboarding completed", + "eventProfileSave": "Profile saves", + "eventProfileBump": "Profile bumps", + "eventDiscoveryView": "Discovery views", + "eventProfileView": "Profile views", + "eventUsernameCopy": "Username copies", + "eventSaveFavorite": "Saved profiles", + "eventIntroRequest": "Intro requests", + "eventReport": "Reports", + "eventBlock": "Blocks", + "eventPremiumUpgrade": "Premium upgrades" + }, "Discovery": { "pageEyebrow": "Discovery", "pageTitle": "Find a language partner on Discord", diff --git a/src/locales/ja.json b/src/locales/ja.json index 98b15a9..882d078 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -276,6 +276,8 @@ "allowAnonymousCopyingDescription": "ログインしていないユーザーが自分のDiscordユーザー名をコピーできるようにする。", "displayTimezoneLabel": "タイムゾーンを表示", "displayTimezoneDescription": "公開プロフィールにタイムゾーンを表示する。", + "productAnalyticsLabel": "プロダクト分析", + "productAnalyticsDescription": "匿名で個人を特定しない利用イベントを共有して、Polycordの改善に協力します。", "appLanguagePlaceholder": "アプリの言語を選択", "exportDataLabel": "アカウントデータをエクスポート", "exportDataDescription": "アカウント、プロフィール、保存済みのプライバシー設定をJSONとしてダウンロードします。", @@ -293,6 +295,36 @@ "deleteAccountRetentionNote": "プロフィールとアカウントの行は削除されます。安全対策、通報、モデレーション、請求、法的記録は、該当する仕組みが存在する場合に保持されることがあります。", "deleteAccountError": "アカウントを削除できませんでした。もう一度お試しください。" }, + "Analytics": { + "title": "プロダクト分析", + "subtitle": "過去{days}日間の主要なプロダクトイベント。", + "totalEvents": "総イベント数", + "uniqueUsers": "ログインユーザー数", + "eventTypes": "イベント種別数", + "emptyTitle": "まだイベントがありません", + "emptyDescription": "ユーザーがプロダクトを利用すると、ここにイベントが表示されます。", + "funnelTitle": "アクティベーションファネル", + "funnelDescription": "サインアップを基準に、ユーザーがコアループをどこまで進んだか。", + "dailyTitle": "日別イベント(過去{days}日間)", + "eventVolumeTitle": "イベント量", + "recentTitle": "最近のイベント", + "audienceAuthenticated": "ログイン済み", + "audienceAnonymous": "匿名", + "eventAuthSignup": "サインアップ", + "eventAuthLogin": "ログイン", + "eventOnboardingStart": "オンボーディング開始", + "eventOnboardingComplete": "オンボーディング完了", + "eventProfileSave": "プロフィール保存", + "eventProfileBump": "プロフィールバンプ", + "eventDiscoveryView": "ディスカバリー表示", + "eventProfileView": "プロフィール表示", + "eventUsernameCopy": "ユーザー名コピー", + "eventSaveFavorite": "お気に入り保存", + "eventIntroRequest": "紹介リクエスト", + "eventReport": "通報", + "eventBlock": "ブロック", + "eventPremiumUpgrade": "プレミアムアップグレード" + }, "Discovery": { "pageEyebrow": "Discovery", "pageTitle": "Discordで言語パートナーを見つける",