From c9fd114d64825c1d28dae17e97af411ff77e1277 Mon Sep 17 00:00:00 2001 From: Paul Brissaud Date: Wed, 11 Mar 2026 00:49:45 +0100 Subject: [PATCH 1/2] fix: prevent race condition in challenge submission (#187) Use atomic conditional UPDATE/INSERT with RETURNING to ensure only one concurrent request can complete a challenge, award XP, and fire PostHog events. Adds a unique index on (userId, challengeId) in userProgress to back the onConflictDoNothing INSERT path. Co-Authored-By: Claude Sonnet 4.6 --- drizzle/0018_faulty_phantom_reporter.sql | 1 + drizzle/meta/0018_snapshot.json | 1702 ++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + server/api/routers/userProgress.ts | 51 +- server/db/schema/challenge.ts | 5 + 5 files changed, 1755 insertions(+), 11 deletions(-) create mode 100644 drizzle/0018_faulty_phantom_reporter.sql create mode 100644 drizzle/meta/0018_snapshot.json diff --git a/drizzle/0018_faulty_phantom_reporter.sql b/drizzle/0018_faulty_phantom_reporter.sql new file mode 100644 index 0000000..93958be --- /dev/null +++ b/drizzle/0018_faulty_phantom_reporter.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX "user_progress_user_challenge_unique_idx" ON "user_progress" USING btree ("user_id","challenge_id"); \ No newline at end of file diff --git a/drizzle/meta/0018_snapshot.json b/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..6f175f0 --- /dev/null +++ b/drizzle/meta/0018_snapshot.json @@ -0,0 +1,1702 @@ +{ + "id": "15cc9599-c74f-4904-b22b-23b0d0426514", + "prevId": "8a0aa038-9ec5-401a-b8bc-d15a56b370d5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_configId_idx": { + "name": "apikey_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_referenceId_idx": { + "name": "apikey_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resend_contact_id": { + "name": "resend_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.challenge": { + "name": "challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "challenge_difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fix'" + }, + "estimated_time": { + "name": "estimated_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "initial_situation": { + "name": "initial_situation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "of_the_week": { + "name": "of_the_week", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "starter_friendly": { + "name": "starter_friendly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "available": { + "name": "available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "challenge_difficulty_idx": { + "name": "challenge_difficulty_idx", + "columns": [ + { + "expression": "difficulty", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "challenge_theme_idx": { + "name": "challenge_theme_idx", + "columns": [ + { + "expression": "theme", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "challenge_type_idx": { + "name": "challenge_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "challenge_theme_difficulty_idx": { + "name": "challenge_theme_difficulty_idx", + "columns": [ + { + "expression": "theme", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "difficulty", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "challenge_title_idx": { + "name": "challenge_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "challenge_created_at_idx": { + "name": "challenge_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "challenge_starter_friendly_idx": { + "name": "challenge_starter_friendly_idx", + "columns": [ + { + "expression": "starter_friendly", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "challenge_available_difficulty_idx": { + "name": "challenge_available_difficulty_idx", + "columns": [ + { + "expression": "available", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "difficulty", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "challenge_theme_challenge_theme_slug_fk": { + "name": "challenge_theme_challenge_theme_slug_fk", + "tableFrom": "challenge", + "tableTo": "challenge_theme", + "columnsFrom": ["theme"], + "columnsTo": ["slug"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "challenge_type_challenge_type_slug_fk": { + "name": "challenge_type_challenge_type_slug_fk", + "tableFrom": "challenge", + "tableTo": "challenge_type", + "columnsFrom": ["type"], + "columnsTo": ["slug"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "challenge_slug_unique": { + "name": "challenge_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.challenge_objective": { + "name": "challenge_objective", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "challenge_id": { + "name": "challenge_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "objective_key": { + "name": "objective_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "objective_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "challenge_objective_challenge_key_idx": { + "name": "challenge_objective_challenge_key_idx", + "columns": [ + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "objective_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "challenge_objective_challenge_id_idx": { + "name": "challenge_objective_challenge_id_idx", + "columns": [ + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "challenge_objective_challenge_id_challenge_id_fk": { + "name": "challenge_objective_challenge_id_challenge_id_fk", + "tableFrom": "challenge_objective", + "tableTo": "challenge", + "columnsFrom": ["challenge_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.challenge_theme": { + "name": "challenge_theme", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.challenge_type": { + "name": "challenge_type", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_progress": { + "name": "user_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_id": { + "name": "challenge_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "challenge_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_progress_user_challenge_unique_idx": { + "name": "user_progress_user_challenge_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_progress_user_status_challenge_idx": { + "name": "user_progress_user_status_challenge_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_progress_challenge_status_idx": { + "name": "user_progress_challenge_status_idx", + "columns": [ + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_progress_user_id_user_id_fk": { + "name": "user_progress_user_id_user_id_fk", + "tableFrom": "user_progress", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_progress_challenge_id_challenge_id_fk": { + "name": "user_progress_challenge_id_challenge_id_fk", + "tableFrom": "user_progress", + "tableTo": "challenge", + "columnsFrom": ["challenge_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_submission": { + "name": "user_submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_id": { + "name": "challenge_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "objectives": { + "name": "objectives", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_submission_user_id_user_id_fk": { + "name": "user_submission_user_id_user_id_fk", + "tableFrom": "user_submission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_submission_challenge_id_challenge_id_fk": { + "name": "user_submission_challenge_id_challenge_id_fk", + "tableFrom": "user_submission", + "tableTo": "challenge", + "columnsFrom": ["challenge_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_xp": { + "name": "user_xp", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "total_xp": { + "name": "total_xp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_xp_total_xp_idx": { + "name": "user_xp_total_xp_idx", + "columns": [ + { + "expression": "total_xp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_xp_user_id_user_id_fk": { + "name": "user_xp_user_id_user_id_fk", + "tableFrom": "user_xp", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_xp_transaction": { + "name": "user_xp_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "xp_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "xp_amount": { + "name": "xp_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "challenge_id": { + "name": "challenge_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_xp_transaction_user_id_idx": { + "name": "user_xp_transaction_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_xp_transaction_user_action_idx": { + "name": "user_xp_transaction_user_action_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_xp_transaction_user_id_user_id_fk": { + "name": "user_xp_transaction_user_id_user_id_fk", + "tableFrom": "user_xp_transaction", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_xp_transaction_challenge_id_challenge_id_fk": { + "name": "user_xp_transaction_challenge_id_challenge_id_fk", + "tableFrom": "user_xp_transaction", + "tableTo": "challenge", + "columnsFrom": ["challenge_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_topic": { + "name": "email_topic", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "email_topic_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "resend_topic_id": { + "name": "resend_topic_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_opt_in": { + "name": "default_opt_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_topic_resend_topic_id_unique": { + "name": "email_topic_resend_topic_id_unique", + "nullsNotDistinct": false, + "columns": ["resend_topic_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_onboarding": { + "name": "user_onboarding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "skipped_at": { + "name": "skipped_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cli_authenticated": { + "name": "cli_authenticated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cluster_initialized": { + "name": "cluster_initialized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_webhook_url": { + "name": "token_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "login_webhook_url": { + "name": "login_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "setup_webhook_url": { + "name": "setup_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_webhook_url": { + "name": "start_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "complete_webhook_url": { + "name": "complete_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_onboarding_user_id_idx": { + "name": "user_onboarding_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_onboarding_user_id_user_id_fk": { + "name": "user_onboarding_user_id_user_id_fk", + "tableFrom": "user_onboarding", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_onboarding_user_id_unique": { + "name": "user_onboarding_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.challenge_difficulty": { + "name": "challenge_difficulty", + "schema": "public", + "values": ["easy", "medium", "hard"] + }, + "public.challenge_status": { + "name": "challenge_status", + "schema": "public", + "values": ["not_started", "in_progress", "completed"] + }, + "public.objective_category": { + "name": "objective_category", + "schema": "public", + "values": ["status", "condition", "log", "event", "connectivity"] + }, + "public.xp_action": { + "name": "xp_action", + "schema": "public", + "values": [ + "challenge_completed", + "daily_streak", + "first_challenge", + "milestone_reached", + "bonus" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9e09269..30d8397 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1772720763978, "tag": "0017_uneven_steel_serpent", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1773186311748, + "tag": "0018_faulty_phantom_reporter", + "breakpoints": true } ] } diff --git a/server/api/routers/userProgress.ts b/server/api/routers/userProgress.ts index ca91ec8..0be105e 100644 --- a/server/api/routers/userProgress.ts +++ b/server/api/routers/userProgress.ts @@ -1,4 +1,4 @@ -import { and, count, desc, eq, sql } from "drizzle-orm"; +import { and, count, desc, eq, ne, sql } from "drizzle-orm"; import { nanoid } from "nanoid"; import { revalidateTag } from "next/cache"; import { z } from "zod"; @@ -645,24 +645,53 @@ export const userProgressRouter = createTRPCRouter({ isFirstChallenge, currentStreak, }); - // Update or create user progress + // Atomic conditional update/insert to prevent race conditions (double XP / duplicate analytics) + let progressUpdated: boolean; if (existingProgress) { - await ctx.db + // Only update if not already completed — if RETURNING is empty, race was lost + const updated = await ctx.db .update(userProgress) .set({ status: "completed", completedAt: new Date(), updatedAt: new Date(), }) - .where(eq(userProgress.id, existingProgress.id)); + .where( + and( + eq(userProgress.id, existingProgress.id), + ne(userProgress.status, "completed"), + ), + ) + .returning({ id: userProgress.id }); + progressUpdated = updated.length > 0; } else { - await ctx.db.insert(userProgress).values({ - id: nanoid(), - userId, - challengeId: challengeData.id, - status: "completed", - completedAt: new Date(), - }); + // onConflictDoNothing + unique index catches concurrent inserts + const inserted = await ctx.db + .insert(userProgress) + .values({ + id: nanoid(), + userId, + challengeId: challengeData.id, + status: "completed", + completedAt: new Date(), + }) + .onConflictDoNothing() + .returning({ id: userProgress.id }); + progressUpdated = inserted.length > 0; + } + + // If a concurrent request already completed this challenge, skip XP and analytics + if (!progressUpdated) { + return { + success: true, + xpAwarded: 0, + totalXp: 0, + rank: "", + rankUp: false, + firstChallenge: isFirstChallenge, + streakBonus: 0, + currentStreak, + }; } // Submission details were already stored before validation check diff --git a/server/db/schema/challenge.ts b/server/db/schema/challenge.ts index 309404b..f9de87a 100644 --- a/server/db/schema/challenge.ts +++ b/server/db/schema/challenge.ts @@ -117,6 +117,11 @@ export const userProgress = pgTable( .notNull(), }, (table) => [ + // Unique constraint to prevent duplicate progress rows and enable onConflictDoNothing + uniqueIndex("user_progress_user_challenge_unique_idx").on( + table.userId, + table.challengeId, + ), // Index composite critique pour la requête des challenges complétés par utilisateur // Utilisé dans la requête: WHERE userId = ? AND status = 'completed' index("user_progress_user_status_challenge_idx").on( From 4be783f69efc757f18cd2a14de00d3901ad318c9 Mon Sep 17 00:00:00 2001 From: Paul Brissaud Date: Wed, 11 Mar 2026 01:09:02 +0100 Subject: [PATCH 2/2] fix: address race condition follow-ups in submitChallenge and completeChallenge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move isFirstChallenge check to after the atomic progress write (only winners evaluate it) and use userXpTransaction count instead of userProgress count — reduces the first-challenge bonus race window for concurrent submissions of different challenges - Replace read-modify-write on userXp.totalXp with an atomic INSERT … ON CONFLICT DO UPDATE SET totalXp = totalXp + delta in both submitChallenge and completeChallenge - Apply the same atomic conditional update/insert pattern to completeChallenge (web UI path) which still used the old non-atomic SELECT-then-UPDATE/INSERT - Add comments clarifying the early-return throws are UX fast-paths, not race guards - Return rank: null (instead of "") in the submitChallenge race-lost early return Co-Authored-By: Claude Sonnet 4.6 --- server/api/routers/userProgress.ts | 197 ++++++++++++++--------------- 1 file changed, 97 insertions(+), 100 deletions(-) diff --git a/server/api/routers/userProgress.ts b/server/api/routers/userProgress.ts index 0be105e..bd7ec4a 100644 --- a/server/api/routers/userProgress.ts +++ b/server/api/routers/userProgress.ts @@ -192,22 +192,60 @@ export const userProgressRouter = createTRPCRouter({ ), ); + // Fast-path for intentional re-submissions; the atomic write below is the actual race guard if (existingProgress?.status === "completed") { throw new Error("Challenge already completed"); } - // Check if this is the user's first challenge - const [completedCount] = await ctx.db + // Atomic conditional update/insert (race guard — the SELECT above is only a UX fast-path) + let progressUpdated: boolean; + if (existingProgress) { + const updated = await ctx.db + .update(userProgress) + .set({ + status: "completed", + completedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(userProgress.id, existingProgress.id), + ne(userProgress.status, "completed"), + ), + ) + .returning({ id: userProgress.id }); + progressUpdated = updated.length > 0; + } else { + const inserted = await ctx.db + .insert(userProgress) + .values({ + id: nanoid(), + userId, + challengeId, + status: "completed", + completedAt: new Date(), + }) + .onConflictDoNothing() + .returning({ id: userProgress.id }); + progressUpdated = inserted.length > 0; + } + + if (!progressUpdated) { + throw new Error("Challenge already completed"); + } + + // Only winners reach here — use xpTransaction count (authoritative source) to detect + // the first challenge, read after the atomic write to minimise the race window + const [completedTransactions] = await ctx.db .select({ count: count() }) - .from(userProgress) + .from(userXpTransaction) .where( and( - eq(userProgress.userId, userId), - eq(userProgress.status, "completed"), + eq(userXpTransaction.userId, userId), + eq(userXpTransaction.action, "challenge_completed"), ), ); - - const isFirstChallenge = (completedCount?.count ?? 0) === 0; + const isFirstChallenge = (completedTransactions?.count ?? 0) === 0; // Get current streak to calculate streak bonus const currentStreak = await calculateStreak(userId); @@ -218,51 +256,20 @@ export const userProgressRouter = createTRPCRouter({ isFirstChallenge, currentStreak, }); - // Update or create user progress - if (existingProgress) { - await ctx.db - .update(userProgress) - .set({ - status: "completed", - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(userProgress.id, existingProgress.id)); - } else { - await ctx.db.insert(userProgress).values({ - id: nanoid(), - userId, - challengeId, - status: "completed", - completedAt: new Date(), - }); - } - - // Check if user has XP record (still used for backward compatibility) - const [existingXp] = await ctx.db - .select() - .from(userXp) - .where(eq(userXp.userId, userId)); - const oldXp = existingXp?.totalXp ?? 0; - const newXp = oldXp + xpGain.total; - - if (existingXp) { - // Update existing XP - await ctx.db - .update(userXp) - .set({ - totalXp: newXp, + // Atomically increment userXp — avoids lost-update races between concurrent winners + const [updatedXp] = await ctx.db + .insert(userXp) + .values({ userId, totalXp: xpGain.total }) + .onConflictDoUpdate({ + target: userXp.userId, + set: { + totalXp: sql`${userXp.totalXp} + ${xpGain.total}`, updatedAt: new Date(), - }) - .where(eq(userXp.userId, userId)); - } else { - // Create new XP record - await ctx.db.insert(userXp).values({ - userId, - totalXp: xpGain.total, - }); - } + }, + }) + .returning({ totalXp: userXp.totalXp }); + const _newXp = updatedXp?.totalXp ?? xpGain.total; // Record base XP transaction await ctx.db.insert(userXpTransaction).values({ @@ -515,6 +522,7 @@ export const userProgressRouter = createTRPCRouter({ ) .limit(1); + // Fast-path for intentional re-submissions; the atomic write below is the actual race guard if (existingProgress?.status === "completed") { throw new Error("Challenge already completed"); } @@ -622,29 +630,6 @@ export const userProgressRouter = createTRPCRouter({ }; } - // Validation passed - calculate XP using XP service - // Check if this is the user's first challenge - const [completedCount] = await ctx.db - .select({ count: count() }) - .from(userProgress) - .where( - and( - eq(userProgress.userId, userId), - eq(userProgress.status, "completed"), - ), - ); - - const isFirstChallenge = (completedCount?.count ?? 0) === 0; - - // Get current streak to calculate streak bonus - const currentStreak = await calculateStreak(userId); - - // Calculate XP using XP service - const xpGain = calculateXPGain({ - difficulty: challengeData.difficulty, - isFirstChallenge, - currentStreak, - }); // Atomic conditional update/insert to prevent race conditions (double XP / duplicate analytics) let progressUpdated: boolean; if (existingProgress) { @@ -686,44 +671,56 @@ export const userProgressRouter = createTRPCRouter({ success: true, xpAwarded: 0, totalXp: 0, - rank: "", + rank: null, rankUp: false, - firstChallenge: isFirstChallenge, + firstChallenge: false, streakBonus: 0, - currentStreak, + currentStreak: 0, }; } + // Only winners reach here — read isFirstChallenge from xpTransaction count (authoritative + // source) *after* the atomic write, so concurrent winners for different challenges see each + // other's committed transaction before awarding the first-challenge bonus + const [completedTransactions] = await ctx.db + .select({ count: count() }) + .from(userXpTransaction) + .where( + and( + eq(userXpTransaction.userId, userId), + eq(userXpTransaction.action, "challenge_completed"), + ), + ); + const isFirstChallenge = (completedTransactions?.count ?? 0) === 0; + + // Get current streak to calculate streak bonus + const currentStreak = await calculateStreak(userId); + + // Calculate XP using XP service + const xpGain = calculateXPGain({ + difficulty: challengeData.difficulty, + isFirstChallenge, + currentStreak, + }); + // Submission details were already stored before validation check // Get old rank before XP update const oldRankInfo = await calculateLevel(userId); - // Check if user has XP record (still used for backward compatibility) - const [existingXp] = await ctx.db - .select() - .from(userXp) - .where(eq(userXp.userId, userId)); - - const oldXp = existingXp?.totalXp ?? 0; - const newXp = oldXp + xpGain.total; - - if (existingXp) { - // Update existing XP - await ctx.db - .update(userXp) - .set({ - totalXp: newXp, + // Atomically increment userXp — avoids lost-update races between concurrent winners + const [updatedXp] = await ctx.db + .insert(userXp) + .values({ userId, totalXp: xpGain.total }) + .onConflictDoUpdate({ + target: userXp.userId, + set: { + totalXp: sql`${userXp.totalXp} + ${xpGain.total}`, updatedAt: new Date(), - }) - .where(eq(userXp.userId, userId)); - } else { - // Create new XP record - await ctx.db.insert(userXp).values({ - userId, - totalXp: xpGain.total, - }); - } + }, + }) + .returning({ totalXp: userXp.totalXp }); + const newXp = updatedXp?.totalXp ?? xpGain.total; // Record base XP transaction await ctx.db.insert(userXpTransaction).values({