From c29599daaa8551dc0006c4c8c90f893fa06fb4fb Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Wed, 10 Jun 2026 13:04:27 +0800 Subject: [PATCH] fix: clamp negative hearts in bootstrap progress --- db/migrations/0005_clamp_user_hearts.sql | 1 + db/migrations/0006_unique_makkari.sql | 1 + db/migrations/meta/0006_snapshot.json | 3366 +++++++++++++++++ db/migrations/meta/_journal.json | 14 + .../services/progress/hearts-service.test.ts | 28 + src/db/schema.ts | 2 + src/services/progress/hearts-service.ts | 18 +- 7 files changed, 3428 insertions(+), 2 deletions(-) create mode 100644 db/migrations/0005_clamp_user_hearts.sql create mode 100644 db/migrations/0006_unique_makkari.sql create mode 100644 db/migrations/meta/0006_snapshot.json diff --git a/db/migrations/0005_clamp_user_hearts.sql b/db/migrations/0005_clamp_user_hearts.sql new file mode 100644 index 0000000..840ca3d --- /dev/null +++ b/db/migrations/0005_clamp_user_hearts.sql @@ -0,0 +1 @@ +UPDATE `users` SET `hearts_remaining` = 0 WHERE `hearts_remaining` < 0; diff --git a/db/migrations/0006_unique_makkari.sql b/db/migrations/0006_unique_makkari.sql new file mode 100644 index 0000000..d4ab884 --- /dev/null +++ b/db/migrations/0006_unique_makkari.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD CONSTRAINT `chk_users_hearts_remaining_nonnegative` CHECK (`users`.`hearts_remaining` >= 0); \ No newline at end of file diff --git a/db/migrations/meta/0006_snapshot.json b/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..62fa50b --- /dev/null +++ b/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,3366 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "19c17c12-e4d0-4cc1-9018-e019d6fbb784", + "prevId": "336479f9-32f5-490f-84f4-c505f206b02b", + "tables": { + "account_migrations": { + "name": "account_migrations", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guest_user_id": { + "name": "guest_user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "formal_user_id": { + "name": "formal_user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('apple','google','phone')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_migration_id": { + "name": "client_migration_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('in_progress','completed','failed')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'in_progress'" + }, + "migration_summary": { + "name": "migration_summary", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uk_migration_guest_client": { + "name": "uk_migration_guest_client", + "columns": [ + "guest_user_id", + "client_migration_id" + ], + "isUnique": true + }, + "uk_migration_guest_provider": { + "name": "uk_migration_guest_provider", + "columns": [ + "guest_user_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": { + "account_migrations_guest_user_id_users_id_fk": { + "name": "account_migrations_guest_user_id_users_id_fk", + "tableFrom": "account_migrations", + "tableTo": "users", + "columnsFrom": [ + "guest_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "account_migrations_formal_user_id_users_id_fk": { + "name": "account_migrations_formal_user_id_users_id_fk", + "tableFrom": "account_migrations", + "tableTo": "users", + "columnsFrom": [ + "formal_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_migrations_id": { + "name": "account_migrations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "achievements": { + "name": "achievements", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('knowledge','behavior')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "achievements_id": { + "name": "achievements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ad_recovery_sessions": { + "name": "ad_recovery_sessions", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('hearts','bonusAttempts','streakProtection')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','settling','completed','failed','expired')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "client_request_id": { + "name": "client_request_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "complete_request_id": { + "name": "complete_request_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "enum('ios','android','harmony','web')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ad_provider": { + "name": "ad_provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ad_placement_id": { + "name": "ad_placement_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_reward_token": { + "name": "provider_reward_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward_snapshot": { + "name": "reward_snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress_before": { + "name": "progress_before", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress_after": { + "name": "progress_after", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_error": { + "name": "provider_error", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duplicate_count": { + "name": "duplicate_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_ad_recovery_user_client_request": { + "name": "uk_ad_recovery_user_client_request", + "columns": [ + "user_id", + "client_request_id" + ], + "isUnique": true + }, + "idx_ad_recovery_user_type_status_created": { + "name": "idx_ad_recovery_user_type_status_created", + "columns": [ + "user_id", + "type", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ad_recovery_sessions_user_id_users_id_fk": { + "name": "ad_recovery_sessions_user_id_users_id_fk", + "tableFrom": "ad_recovery_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ad_recovery_sessions_id": { + "name": "ad_recovery_sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "admin_audit_log": { + "name": "admin_audit_log", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "admin_id": { + "name": "admin_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "admin_audit_log_id": { + "name": "admin_audit_log_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "admin_users": { + "name": "admin_users", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','super_admin')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'admin'" + }, + "is_active": { + "name": "is_active", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_admin_username": { + "name": "uk_admin_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "admin_users_id": { + "name": "admin_users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_settings_key": { + "name": "app_settings_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "question_count": { + "name": "question_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "enum('active','inactive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_slug": { + "name": "uk_slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "categories_id": { + "name": "categories_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "challenge_session_answers": { + "name": "challenge_session_answers", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submit_request_id": { + "name": "submit_request_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer_order": { + "name": "answer_order", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "correct": { + "name": "correct", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_ms": { + "name": "time_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "combo_count": { + "name": "combo_count", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "result_snapshot": { + "name": "result_snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_challenge_answer_session_question": { + "name": "uk_challenge_answer_session_question", + "columns": [ + "session_id", + "question_id" + ], + "isUnique": true + }, + "uk_challenge_answer_session_request": { + "name": "uk_challenge_answer_session_request", + "columns": [ + "session_id", + "submit_request_id" + ], + "isUnique": true + }, + "idx_challenge_answer_user_submitted": { + "name": "idx_challenge_answer_user_submitted", + "columns": [ + "user_id", + "submitted_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "challenge_session_answers_session_id_challenge_sessions_id_fk": { + "name": "challenge_session_answers_session_id_challenge_sessions_id_fk", + "tableFrom": "challenge_session_answers", + "tableTo": "challenge_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_session_answers_user_id_users_id_fk": { + "name": "challenge_session_answers_user_id_users_id_fk", + "tableFrom": "challenge_session_answers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_session_answers_question_id_questions_id_fk": { + "name": "challenge_session_answers_question_id_questions_id_fk", + "tableFrom": "challenge_session_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "challenge_session_answers_id": { + "name": "challenge_session_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "challenge_sessions": { + "name": "challenge_sessions", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_id": { + "name": "track_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_id": { + "name": "chapter_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','in_progress','completed','abandoned','expired')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "client_request_id": { + "name": "client_request_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "complete_request_id": { + "name": "complete_request_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "question_ids": { + "name": "question_ids", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_questions": { + "name": "total_questions", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5 + }, + "answered_count": { + "name": "answered_count", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "correct_count": { + "name": "correct_count", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "high_reward_eligible": { + "name": "high_reward_eligible", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "reward_snapshot": { + "name": "reward_snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress_before": { + "name": "progress_before", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress_after": { + "name": "progress_after", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_challenge_session_user_client_request": { + "name": "uk_challenge_session_user_client_request", + "columns": [ + "user_id", + "client_request_id" + ], + "isUnique": true + }, + "uk_challenge_session_user_complete_request": { + "name": "uk_challenge_session_user_complete_request", + "columns": [ + "user_id", + "complete_request_id" + ], + "isUnique": true + }, + "idx_challenge_session_user_status_created": { + "name": "idx_challenge_session_user_status_created", + "columns": [ + "user_id", + "status", + "created_at" + ], + "isUnique": false + }, + "idx_challenge_session_chapter_status": { + "name": "idx_challenge_session_chapter_status", + "columns": [ + "chapter_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "challenge_sessions_user_id_users_id_fk": { + "name": "challenge_sessions_user_id_users_id_fk", + "tableFrom": "challenge_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_sessions_category_id_categories_id_fk": { + "name": "challenge_sessions_category_id_categories_id_fk", + "tableFrom": "challenge_sessions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_sessions_chapter_id_skill_tree_id_fk": { + "name": "challenge_sessions_chapter_id_skill_tree_id_fk", + "tableFrom": "challenge_sessions", + "tableTo": "skill_tree", + "columnsFrom": [ + "chapter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "challenge_sessions_id": { + "name": "challenge_sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "inventory_transactions": { + "name": "inventory_transactions", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inventory_item_id": { + "name": "inventory_item_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "enum('coins','streak_shield','double_xp_potion','heart_supply','hint_feather','mascot_outfit')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "enum('grant','consume','adjust')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity_delta": { + "name": "quantity_delta", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balance_after": { + "name": "balance_after", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "enum('challenge','daily_task','level_up','theme_node','chest','shop_purchase','ad_recovery','subscription','admin_grant','system_adjust','leaderboard_settlement')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_inventory_transaction_idempotency": { + "name": "uk_inventory_transaction_idempotency", + "columns": [ + "user_id", + "idempotency_key" + ], + "isUnique": true + }, + "idx_inventory_transaction_user_created": { + "name": "idx_inventory_transaction_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_inventory_transaction_source": { + "name": "idx_inventory_transaction_source", + "columns": [ + "source_type", + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inventory_transactions_user_id_users_id_fk": { + "name": "inventory_transactions_user_id_users_id_fk", + "tableFrom": "inventory_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fk_inv_tx_item": { + "name": "fk_inv_tx_item", + "tableFrom": "inventory_transactions", + "tableTo": "user_inventory_items", + "columnsFrom": [ + "inventory_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "inventory_transactions_id": { + "name": "inventory_transactions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_cards": { + "name": "knowledge_cards", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deep_dive": { + "name": "deep_dive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_ref": { + "name": "source_ref", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_question": { + "name": "uk_question", + "columns": [ + "question_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "knowledge_cards_question_id_questions_id_fk": { + "name": "knowledge_cards_question_id_questions_id_fk", + "tableFrom": "knowledge_cards", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_cards_id": { + "name": "knowledge_cards_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "leaderboard_snapshots": { + "name": "leaderboard_snapshots", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "enum('bronze','silver','gold','platinum','diamond','master','grandmaster','champion','legend','mythic')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weekly_xp": { + "name": "weekly_xp", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "rank": { + "name": "rank", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "league": { + "name": "league", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward_snapshot": { + "name": "reward_snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settled_at": { + "name": "settled_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "week_end": { + "name": "week_end", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_leaderboard_snapshot_user_week": { + "name": "uk_leaderboard_snapshot_user_week", + "columns": [ + "user_id", + "week_start" + ], + "isUnique": true + }, + "idx_leaderboard_snapshot_group_rank": { + "name": "idx_leaderboard_snapshot_group_rank", + "columns": [ + "group_id", + "week_start", + "rank" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "leaderboard_snapshots_id": { + "name": "leaderboard_snapshots_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "question_ratings": { + "name": "question_ratings", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "enum('good','bad')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_user_question_rating": { + "name": "uk_user_question_rating", + "columns": [ + "user_id", + "question_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "question_ratings_id": { + "name": "question_ratings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions": { + "name": "questions", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stem": { + "name": "stem", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "enum('text','image','video','audio')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "correct_answer": { + "name": "correct_answer", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distractors": { + "name": "distractors", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dynamic_difficulty": { + "name": "dynamic_difficulty", + "type": "decimal(3,1)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "enum('system','ugc')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'system'" + }, + "creator_id": { + "name": "creator_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('draft','reviewing','published','archived')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "stats": { + "name": "stats", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('{\"timesAnswered\":0,\"correctRate\":0,\"avgTimeMs\":0}')" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_id": { + "name": "questions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "reward_ledger": { + "name": "reward_ledger", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "enum('challenge_answer','challenge_completion','daily_task','streak_milestone','level_up','theme_node','knowledge_card','chest','shop_purchase','ad_recovery','leaderboard_settlement','subscription','admin_grant','system_adjust')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','settling','completed','failed','reversed')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "reward_snapshot": { + "name": "reward_snapshot", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_deltas": { + "name": "resource_deltas", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_before": { + "name": "state_before", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_after": { + "name": "state_after", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settled_at": { + "name": "settled_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_reward_ledger_user_idempotency": { + "name": "uk_reward_ledger_user_idempotency", + "columns": [ + "user_id", + "idempotency_key" + ], + "isUnique": true + }, + "idx_reward_ledger_user_status_created": { + "name": "idx_reward_ledger_user_status_created", + "columns": [ + "user_id", + "status", + "created_at" + ], + "isUnique": false + }, + "idx_reward_ledger_source": { + "name": "idx_reward_ledger_source", + "columns": [ + "source_type", + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reward_ledger_user_id_users_id_fk": { + "name": "reward_ledger_user_id_users_id_fk", + "tableFrom": "reward_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reward_ledger_id": { + "name": "reward_ledger_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skill_tree": { + "name": "skill_tree", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "questions_required": { + "name": "questions_required", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 4 + }, + "pass_threshold": { + "name": "pass_threshold", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 2 + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "skill_tree_category_id_categories_id_fk": { + "name": "skill_tree_category_id_categories_id_fk", + "tableFrom": "skill_tree", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tree_id": { + "name": "skill_tree_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "enum('free','pro','proplus')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'free'" + }, + "platform": { + "name": "platform", + "type": "enum('huawei','apple','google')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purchase_token": { + "name": "purchase_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_renew": { + "name": "auto_renew", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "enum('active','expired','cancelled')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_subscription_user": { + "name": "uk_subscription_user", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscriptions_id": { + "name": "subscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_achievements": { + "name": "user_achievements", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "achievement_id": { + "name": "achievement_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_user_achievement": { + "name": "uk_user_achievement", + "columns": [ + "user_id", + "achievement_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_achievements_id": { + "name": "user_achievements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_chapter_progress": { + "name": "user_chapter_progress", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_id": { + "name": "chapter_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('locked','unlocked','passed','perfect')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'locked'" + }, + "best_correct_count": { + "name": "best_correct_count", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uk_user_chapter": { + "name": "uk_user_chapter", + "columns": [ + "user_id", + "chapter_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_chapter_progress_user_id_users_id_fk": { + "name": "user_chapter_progress_user_id_users_id_fk", + "tableFrom": "user_chapter_progress", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_chapter_progress_chapter_id_skill_tree_id_fk": { + "name": "user_chapter_progress_chapter_id_skill_tree_id_fk", + "tableFrom": "user_chapter_progress", + "tableTo": "skill_tree", + "columnsFrom": [ + "chapter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_chapter_progress_id": { + "name": "user_chapter_progress_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_daily_progress": { + "name": "user_daily_progress", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress_date": { + "name": "progress_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + }, + "first_challenge_session_id": { + "name": "first_challenge_session_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_challenge_completed_at": { + "name": "first_challenge_completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "challenge_sessions_completed": { + "name": "challenge_sessions_completed", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "high_reward_sessions_max": { + "name": "high_reward_sessions_max", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "high_reward_sessions_used": { + "name": "high_reward_sessions_used", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "high_reward_sessions_restored": { + "name": "high_reward_sessions_restored", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "daily_tasks_completed": { + "name": "daily_tasks_completed", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "daily_tasks_reward_claimed": { + "name": "daily_tasks_reward_claimed", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "xp_earned": { + "name": "xp_earned", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "coins_earned": { + "name": "coins_earned", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "streak_counted": { + "name": "streak_counted", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_daily_progress_user_date": { + "name": "uk_daily_progress_user_date", + "columns": [ + "user_id", + "progress_date" + ], + "isUnique": true + }, + "idx_daily_progress_date": { + "name": "idx_daily_progress_date", + "columns": [ + "progress_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_daily_progress_user_id_users_id_fk": { + "name": "user_daily_progress_user_id_users_id_fk", + "tableFrom": "user_daily_progress", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fk_daily_progress_session": { + "name": "fk_daily_progress_session", + "tableFrom": "user_daily_progress", + "tableTo": "challenge_sessions", + "columnsFrom": [ + "first_challenge_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_daily_progress_id": { + "name": "user_daily_progress_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_daily_tasks": { + "name": "user_daily_tasks", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daily_progress_id": { + "name": "daily_progress_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_date": { + "name": "task_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_type": { + "name": "task_type", + "type": "enum('complete_challenge','earn_xp','answer_correct','review_explanation','use_item','watch_ad')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_count": { + "name": "target_count", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "current_count": { + "name": "current_count", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "enum('active','completed','reward_claimed','expired')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "reward_snapshot": { + "name": "reward_snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward_claimed_at": { + "name": "reward_claimed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_daily_task_user_date_task": { + "name": "uk_daily_task_user_date_task", + "columns": [ + "user_id", + "task_date", + "task_id" + ], + "isUnique": true + }, + "idx_daily_task_progress_status": { + "name": "idx_daily_task_progress_status", + "columns": [ + "daily_progress_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_daily_tasks_daily_progress_id_user_daily_progress_id_fk": { + "name": "user_daily_tasks_daily_progress_id_user_daily_progress_id_fk", + "tableFrom": "user_daily_tasks", + "tableTo": "user_daily_progress", + "columnsFrom": [ + "daily_progress_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_daily_tasks_user_id_users_id_fk": { + "name": "user_daily_tasks_user_id_users_id_fk", + "tableFrom": "user_daily_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_daily_tasks_id": { + "name": "user_daily_tasks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_feedback": { + "name": "user_feedback", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_context": { + "name": "page_context", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_feedback_id": { + "name": "user_feedback_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_inventory_items": { + "name": "user_inventory_items", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "enum('streak_shield','double_xp_potion','heart_supply','hint_feather','mascot_outfit')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "active_until": { + "name": "active_until", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_inventory_user_item": { + "name": "uk_inventory_user_item", + "columns": [ + "user_id", + "item_id" + ], + "isUnique": true + }, + "idx_inventory_user_active": { + "name": "idx_inventory_user_active", + "columns": [ + "user_id", + "active_until" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_inventory_items_user_id_users_id_fk": { + "name": "user_inventory_items_user_id_users_id_fk", + "tableFrom": "user_inventory_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_inventory_items_id": { + "name": "user_inventory_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_progress": { + "name": "user_progress", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "correct": { + "name": "correct", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_ms": { + "name": "time_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "answered_at": { + "name": "answered_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_answered": { + "name": "idx_user_answered", + "columns": [ + "user_id", + "answered_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_progress_user_id_users_id_fk": { + "name": "user_progress_user_id_users_id_fk", + "tableFrom": "user_progress", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_progress_question_id_questions_id_fk": { + "name": "user_progress_question_id_questions_id_fk", + "tableFrom": "user_progress", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_progress_id": { + "name": "user_progress_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_region_change_logs": { + "name": "user_region_change_logs", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_region_code": { + "name": "from_region_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_region_code": { + "name": "to_region_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_region_change_user_changed": { + "name": "idx_region_change_user_changed", + "columns": [ + "user_id", + "changed_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_region_change_logs_user_id_users_id_fk": { + "name": "user_region_change_logs_user_id_users_id_fk", + "tableFrom": "user_region_change_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_region_change_logs_id": { + "name": "user_region_change_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_wallets": { + "name": "user_wallets", + "columns": { + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "coins_balance": { + "name": "coins_balance", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "lifetime_coins_earned": { + "name": "lifetime_coins_earned", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "lifetime_coins_spent": { + "name": "lifetime_coins_spent", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_users_id_fk": { + "name": "user_wallets_user_id_users_id_fk", + "tableFrom": "user_wallets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_wallets_user_id": { + "name": "user_wallets_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_weekly_xp": { + "name": "user_weekly_xp", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "week_end": { + "name": "week_end", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + }, + "xp_earned": { + "name": "xp_earned", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "challenge_sessions_completed": { + "name": "challenge_sessions_completed", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rank": { + "name": "rank", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settled": { + "name": "settled", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "settled_at": { + "name": "settled_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_xp_at": { + "name": "last_xp_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_refresh_at": { + "name": "next_refresh_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_weekly_xp_user_week": { + "name": "uk_weekly_xp_user_week", + "columns": [ + "user_id", + "week_start" + ], + "isUnique": true + }, + "idx_weekly_xp_group_rank": { + "name": "idx_weekly_xp_group_rank", + "columns": [ + "group_id", + "week_start", + "xp_earned" + ], + "isUnique": false + }, + "idx_weekly_xp_week_settled": { + "name": "idx_weekly_xp_week_settled", + "columns": [ + "week_start", + "settled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_weekly_xp_user_id_users_id_fk": { + "name": "user_weekly_xp_user_id_users_id_fk", + "tableFrom": "user_weekly_xp", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_weekly_xp_id": { + "name": "user_weekly_xp_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "enum('huawei','guest','phone','apple','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nickname": { + "name": "nickname", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "enum('free','pro','proplus')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'free'" + }, + "xp_total": { + "name": "xp_total", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "streak_days": { + "name": "streak_days", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "streak_last_date": { + "name": "streak_last_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hearts_remaining": { + "name": "hearts_remaining", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5 + }, + "hearts_last_restore": { + "name": "hearts_last_restore", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "daily_xp_goal": { + "name": "daily_xp_goal", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 50 + }, + "daily_xp_earned": { + "name": "daily_xp_earned", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "daily_xp_date": { + "name": "daily_xp_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_theme": { + "name": "current_theme", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'inkTeal'" + }, + "active_track_id": { + "name": "active_track_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region_code": { + "name": "region_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region_selected_at": { + "name": "region_selected_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region_changed_at": { + "name": "region_changed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "daily_attempts_left": { + "name": "daily_attempts_left", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5 + }, + "daily_attempts_date": { + "name": "daily_attempts_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "check_in_days": { + "name": "check_in_days", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_check_in_date": { + "name": "last_check_in_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "streak_protected_until": { + "name": "streak_protected_until", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_auth": { + "name": "uk_auth", + "columns": [ + "auth_type", + "auth_id" + ], + "isUnique": true + }, + "idx_users_region": { + "name": "idx_users_region", + "columns": [ + "region_code" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "chk_users_hearts_remaining_nonnegative": { + "name": "chk_users_hearts_remaining_nonnegative", + "value": "`users`.`hearts_remaining` >= 0" + } + } + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 94f9d91..872d1c3 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -36,6 +36,20 @@ "when": 1780903327517, "tag": "0004_user_region", "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1781066680000, + "tag": "0005_clamp_user_hearts", + "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1781066751553, + "tag": "0006_unique_makkari", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/__tests__/services/progress/hearts-service.test.ts b/src/__tests__/services/progress/hearts-service.test.ts index d7695a4..f26e0a3 100644 --- a/src/__tests__/services/progress/hearts-service.test.ts +++ b/src/__tests__/services/progress/hearts-service.test.ts @@ -137,5 +137,33 @@ describe('hearts-service', () => { expect(result.success).toBe(false); expect(result.remaining).toBe(0); }); + + it('treats negative stored hearts as 0 when deducting', async () => { + vi.mocked(db.select) + .mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: -11 }]) as never) + .mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }]) as never); + + const { deductHeart } = await import('../../../services/progress/hearts-service.js'); + const result = await deductHeart('user-1'); + + expect(result).toEqual({ success: false, remaining: 0 }); + expect(db.update).not.toHaveBeenCalled(); + }); + }); + + describe('getHearts', () => { + it('clamps and repairs negative stored hearts before bootstrap can expose them', async () => { + vi.mocked(db.select).mockReturnValueOnce( + selectReturning([{ tier: 'free', heartsRemaining: -11, heartsLastRestore: null }]) as never, + ); + vi.mocked(db.update).mockReturnValue(updateReturning() as never); + + const { getHearts } = await import('../../../services/progress/hearts-service.js'); + const result = await getHearts('user-1'); + + expect(result).toEqual({ remaining: 0, max: 5, lastRestore: null }); + expect(db.update).toHaveBeenCalledOnce(); + expect(vi.mocked(db.update).mock.results[0]?.value.set).toHaveBeenCalledWith({ heartsRemaining: 0 }); + }); }); }); diff --git a/src/db/schema.ts b/src/db/schema.ts index e3438b2..c2e8514 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -14,6 +14,7 @@ import { uniqueIndex, foreignKey, index, + check, } from 'drizzle-orm/mysql-core'; import { sql } from 'drizzle-orm'; @@ -50,6 +51,7 @@ export const users = mysqlTable('users', { }, (table) => [ uniqueIndex('uk_auth').on(table.authType, table.authId), index('idx_users_region').on(table.regionCode), + check('chk_users_hearts_remaining_nonnegative', sql`${table.heartsRemaining} >= 0`), ]); // ── Categories ───────────────────────────────────────────────────── diff --git a/src/services/progress/hearts-service.ts b/src/services/progress/hearts-service.ts index c940b0e..17081b9 100644 --- a/src/services/progress/hearts-service.ts +++ b/src/services/progress/hearts-service.ts @@ -21,6 +21,11 @@ function toMs(value: Date | string | null): number | null { return value.getTime(); } +function clampHearts(value: number | null | undefined, max: number): number { + const current = value ?? max; + return Math.min(Math.max(current, 0), max); +} + /** * Get the user's current hearts, accounting for auto-restore. * Auto-restore: 1 heart per 30 minutes if below MAX_FREE_HEARTS. @@ -50,7 +55,8 @@ export async function getHearts(userId: string): Promise { }; } - let remaining = user.heartsRemaining ?? MAX_FREE_HEARTS; + const rawRemaining = user.heartsRemaining ?? MAX_FREE_HEARTS; + let remaining = clampHearts(rawRemaining, MAX_FREE_HEARTS); const lastMs = toMs(user.heartsLastRestore); // Calculate auto-restore @@ -69,6 +75,14 @@ export async function getHearts(userId: string): Promise { } } + // 历史脏数据或外部写入可能留下负数/超上限;读取时修正,避免 bootstrap 透传异常值。 + if (rawRemaining !== remaining) { + await db + .update(users) + .set({ heartsRemaining: remaining }) + .where(eq(users.id, userId)); + } + return { remaining, max: MAX_FREE_HEARTS, @@ -113,7 +127,7 @@ export async function deductHeart(userId: string): Promise<{ success: boolean; r return { success: true, remaining: PRO_HEARTS }; } - const current = user.heartsRemaining ?? MAX_FREE_HEARTS; + const current = clampHearts(user.heartsRemaining, MAX_FREE_HEARTS); // New-user protection: floor = 1 heart for accounts ≤3 days old const protectedFloor = await isNewUserProtected(userId)