diff --git a/.gitignore b/.gitignore index ebf006e..8c4818e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ node_modules/ dist/ .env *.log -db/migrations/ # Claude Code .claude/ diff --git a/db/migrations/0000_melodic_blacklash.sql b/db/migrations/0000_melodic_blacklash.sql new file mode 100644 index 0000000..8bd1b12 --- /dev/null +++ b/db/migrations/0000_melodic_blacklash.sql @@ -0,0 +1,201 @@ +CREATE TABLE `achievements` ( + `id` char(36) NOT NULL, + `type` enum('knowledge','behavior') NOT NULL, + `name` varchar(100) NOT NULL, + `description` varchar(300) NOT NULL, + `icon_url` varchar(500), + `condition` json NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `achievements_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `admin_audit_log` ( + `id` int AUTO_INCREMENT NOT NULL, + `admin_id` varchar(36) NOT NULL, + `action` varchar(10) NOT NULL, + `resource` varchar(500), + `details` json, + `ip_address` varchar(45), + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `admin_audit_log_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `admin_users` ( + `id` char(36) NOT NULL, + `username` varchar(50) NOT NULL, + `password_hash` varchar(255) NOT NULL, + `role` enum('admin','super_admin') DEFAULT 'admin', + `is_active` tinyint DEFAULT 1, + `last_login_at` datetime, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `admin_users_id` PRIMARY KEY(`id`), + CONSTRAINT `uk_admin_username` UNIQUE(`username`) +); +--> statement-breakpoint +CREATE TABLE `categories` ( + `id` varchar(50) NOT NULL, + `name` varchar(100) NOT NULL, + `slug` varchar(100) NOT NULL, + `parent_id` varchar(50), + `sort_order` int DEFAULT 0, + `question_count` int DEFAULT 0, + `status` enum('active','inactive') DEFAULT 'active', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `categories_id` PRIMARY KEY(`id`), + CONSTRAINT `uk_slug` UNIQUE(`slug`) +); +--> statement-breakpoint +CREATE TABLE `knowledge_cards` ( + `id` char(36) NOT NULL, + `question_id` char(36) NOT NULL, + `summary` varchar(300) NOT NULL, + `deep_dive` text, + `source_ref` varchar(500), + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `knowledge_cards_id` PRIMARY KEY(`id`), + CONSTRAINT `uk_question` UNIQUE(`question_id`) +); +--> statement-breakpoint +CREATE TABLE `leaderboard_snapshots` ( + `id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `tier` enum('bronze','silver','gold','platinum','diamond','master','grandmaster','champion','legend','mythic') NOT NULL, + `weekly_xp` int DEFAULT 0, + `rank` int, + `league` varchar(50), + `week_start` date, + `week_end` date, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `leaderboard_snapshots_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `question_ratings` ( + `id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `question_id` char(36) NOT NULL, + `rating` enum('good','bad') NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `question_ratings_id` PRIMARY KEY(`id`), + CONSTRAINT `uk_user_question_rating` UNIQUE(`user_id`,`question_id`) +); +--> statement-breakpoint +CREATE TABLE `questions` ( + `id` char(36) NOT NULL, + `stem` json NOT NULL, + `content_type` enum('text','image','video','audio') NOT NULL, + `correct_answer` varchar(500) NOT NULL, + `distractors` json NOT NULL, + `category_id` varchar(50) NOT NULL, + `difficulty` tinyint, + `dynamic_difficulty` decimal(3,1), + `source` enum('system','ugc') DEFAULT 'system', + `creator_id` char(36), + `status` enum('draft','reviewing','published','archived') DEFAULT 'draft', + `stats` json DEFAULT ('{"timesAnswered":0,"correctRate":0,"avgTimeMs":0}'), + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `questions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `skill_tree` ( + `id` char(36) NOT NULL, + `category_id` varchar(50) NOT NULL, + `title` varchar(100) NOT NULL, + `parent_id` char(36), + `sort_order` int DEFAULT 0, + `questions_required` tinyint DEFAULT 4, + `pass_threshold` tinyint DEFAULT 2, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `skill_tree_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `subscriptions` ( + `id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `tier` enum('free','pro','proplus') DEFAULT 'free', + `platform` enum('huawei','apple','google'), + `purchase_token` varchar(500), + `expires_at` datetime, + `auto_renew` tinyint DEFAULT 0, + `status` enum('active','expired','cancelled') DEFAULT 'active', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `subscriptions_id` PRIMARY KEY(`id`), + CONSTRAINT `uk_subscription_user` UNIQUE(`user_id`) +); +--> statement-breakpoint +CREATE TABLE `user_achievements` ( + `id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `achievement_id` char(36) NOT NULL, + `unlocked_at` datetime DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `user_achievements_id` PRIMARY KEY(`id`), + CONSTRAINT `uk_user_achievement` UNIQUE(`user_id`,`achievement_id`) +); +--> statement-breakpoint +CREATE TABLE `user_chapter_progress` ( + `id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `chapter_id` char(36) NOT NULL, + `status` enum('locked','unlocked','passed','perfect') DEFAULT 'locked', + `best_correct_count` tinyint DEFAULT 0, + `attempts` int DEFAULT 0, + `completed_at` datetime, + CONSTRAINT `user_chapter_progress_id` PRIMARY KEY(`id`), + CONSTRAINT `uk_user_chapter` UNIQUE(`user_id`,`chapter_id`) +); +--> statement-breakpoint +CREATE TABLE `user_feedback` ( + `id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `content` text NOT NULL, + `contact` varchar(255), + `page_context` varchar(200), + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `user_feedback_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `user_progress` ( + `id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `question_id` char(36) NOT NULL, + `correct` tinyint NOT NULL, + `time_ms` int, + `answered_at` datetime DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `user_progress_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` char(36) NOT NULL, + `auth_type` enum('huawei','guest','phone','apple','google') NOT NULL, + `auth_id` varchar(255) NOT NULL, + `nickname` varchar(50), + `avatar_url` varchar(500), + `tier` enum('free','pro','proplus') DEFAULT 'free', + `xp_total` int DEFAULT 0, + `streak_days` int DEFAULT 0, + `streak_last_date` date, + `hearts_remaining` tinyint DEFAULT 5, + `hearts_last_restore` datetime, + `daily_xp_goal` smallint DEFAULT 50, + `daily_xp_earned` smallint DEFAULT 0, + `daily_xp_date` date, + `current_theme` varchar(20) DEFAULT 'inkTeal', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `users_id` PRIMARY KEY(`id`), + CONSTRAINT `uk_auth` UNIQUE(`auth_type`,`auth_id`) +); +--> statement-breakpoint +ALTER TABLE `knowledge_cards` ADD CONSTRAINT `knowledge_cards_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `questions` ADD CONSTRAINT `questions_category_id_categories_id_fk` FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `skill_tree` ADD CONSTRAINT `skill_tree_category_id_categories_id_fk` FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_chapter_progress` ADD CONSTRAINT `user_chapter_progress_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_chapter_progress` ADD CONSTRAINT `user_chapter_progress_chapter_id_skill_tree_id_fk` FOREIGN KEY (`chapter_id`) REFERENCES `skill_tree`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_progress` ADD CONSTRAINT `user_progress_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_progress` ADD CONSTRAINT `user_progress_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_user_week` ON `leaderboard_snapshots` (`user_id`,`week_start`);--> statement-breakpoint +CREATE INDEX `idx_user_answered` ON `user_progress` (`user_id`,`answered_at`); \ No newline at end of file diff --git a/db/migrations/0001_sturdy_invaders.sql b/db/migrations/0001_sturdy_invaders.sql new file mode 100644 index 0000000..841f429 --- /dev/null +++ b/db/migrations/0001_sturdy_invaders.sql @@ -0,0 +1,6 @@ +ALTER TABLE `users` ADD `active_track_id` varchar(50);--> statement-breakpoint +ALTER TABLE `users` ADD `daily_attempts_left` smallint DEFAULT 5;--> statement-breakpoint +ALTER TABLE `users` ADD `daily_attempts_date` date;--> statement-breakpoint +ALTER TABLE `users` ADD `check_in_days` int DEFAULT 0;--> statement-breakpoint +ALTER TABLE `users` ADD `last_check_in_date` date;--> statement-breakpoint +ALTER TABLE `users` ADD `streak_protected_until` datetime; \ No newline at end of file diff --git a/db/migrations/meta/0000_snapshot.json b/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..a0cc5e9 --- /dev/null +++ b/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,1368 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "6c0dff68-1942-488f-9b33-67bb739dee2b", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "league": { + "name": "league", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "week_end": { + "name": "week_end", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_week": { + "name": "idx_user_week", + "columns": [ + "user_id", + "week_start" + ], + "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": {} + }, + "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_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_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": {} + }, + "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'" + }, + "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 + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/0001_snapshot.json b/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..e56046b --- /dev/null +++ b/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1412 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "5bab5fce-302f-485a-a496-e4deeafc2269", + "prevId": "6c0dff68-1942-488f-9b33-67bb739dee2b", + "tables": { + "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": {} + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "league": { + "name": "league", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "week_end": { + "name": "week_end", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_week": { + "name": "idx_user_week", + "columns": [ + "user_id", + "week_start" + ], + "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": {} + }, + "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_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_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": {} + }, + "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 + }, + "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 + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "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 new file mode 100644 index 0000000..afab203 --- /dev/null +++ b/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1775891974121, + "tag": "0000_melodic_blacklash", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1777827874032, + "tag": "0001_sturdy_invaders", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/db/seeds/index.ts b/db/seeds/index.ts index d2e7d05..17193ee 100644 --- a/db/seeds/index.ts +++ b/db/seeds/index.ts @@ -1,6 +1,6 @@ import { v4 as uuid } from 'uuid'; import { db } from '../../src/db/client.js'; -import { categories, questions, knowledgeCards, skillTree, achievements, adminUsers } from '../../src/db/schema.js'; +import { categories, questions, knowledgeCards, skillTree, achievements } from '../../src/db/schema.js'; import { eq } from 'drizzle-orm'; import * as adminAuthService from '../../src/services/admin/admin-auth.js'; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..1b7e835 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,26 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/**', 'coverage/**', 'node_modules/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }], + }, + }, +); diff --git a/src/__tests__/services/learning/progress-summary-service.test.ts b/src/__tests__/services/learning/progress-summary-service.test.ts new file mode 100644 index 0000000..205c6c2 --- /dev/null +++ b/src/__tests__/services/learning/progress-summary-service.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { getDailyAttemptsMax, getLevelInfo, getNextHeartRestoreAt } from '../../../services/learning/progress-summary-service.js'; + +describe('progress-summary-service', () => { + it('calculates level and remaining XP from total XP', () => { + expect(getLevelInfo(0)).toEqual({ level: 1, xpToNextLevel: 400 }); + expect(getLevelInfo(6680)).toEqual({ level: 17, xpToNextLevel: 120 }); + }); + + it('uses tier-specific daily attempt limits', () => { + expect(getDailyAttemptsMax('free')).toBe(5); + expect(getDailyAttemptsMax('pro')).toBe(10); + expect(getDailyAttemptsMax('proplus')).toBe(20); + }); + + it('returns the next heart restore time only when hearts are not full', () => { + expect(getNextHeartRestoreAt('2026-05-04T00:00:00.000Z', 4, 5)).toBe('2026-05-04T00:30:00.000Z'); + expect(getNextHeartRestoreAt('2026-05-04T00:00:00.000Z', 5, 5)).toBeNull(); + }); +}); diff --git a/src/__tests__/services/shop/shop-service.test.ts b/src/__tests__/services/shop/shop-service.test.ts new file mode 100644 index 0000000..b90b558 --- /dev/null +++ b/src/__tests__/services/shop/shop-service.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { getShopBenefits } from '../../../services/shop/shop-service.js'; + +describe('shop-service', () => { + it('returns the client shop benefit catalog', async () => { + const benefits = await getShopBenefits(); + + expect(benefits.map((item) => item.id)).toEqual([ + 'restore-hearts', + 'restore-attempts', + 'protect-streak', + 'duoqi-plus', + ]); + expect(benefits.every((item) => item.enabled)).toBe(true); + }); +}); diff --git a/src/__tests__/services/subscription/subscription-api-service.test.ts b/src/__tests__/services/subscription/subscription-api-service.test.ts new file mode 100644 index 0000000..1dcd5e7 --- /dev/null +++ b/src/__tests__/services/subscription/subscription-api-service.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { verifyClientSubscription } from '../../../services/subscription/subscription-api-service.js'; + +describe('subscription-api-service', () => { + it('returns an explicit error for unsupported platforms', async () => { + await expect( + verifyClientSubscription('user-1', 'apple', 'token', 'product-1', 'pro'), + ).rejects.toMatchObject({ + code: 'UNSUPPORTED_PLATFORM', + statusCode: 400, + }); + }); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index b62bffb..95f1ab0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -35,6 +35,12 @@ export const users = mysqlTable('users', { dailyXpEarned: smallint('daily_xp_earned').default(0), dailyXpDate: date('daily_xp_date'), currentTheme: varchar('current_theme', { length: 20 }).default('inkTeal'), + activeTrackId: varchar('active_track_id', { length: 50 }), + dailyAttemptsLeft: smallint('daily_attempts_left').default(5), + dailyAttemptsDate: date('daily_attempts_date'), + checkInDays: int('check_in_days').default(0), + lastCheckInDate: date('last_check_in_date'), + streakProtectedUntil: datetime('streak_protected_until'), createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), }, (table) => [ diff --git a/src/index.ts b/src/index.ts index 46ad68f..09baf84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { quizRoutes } from './routes/quiz.js'; import { progressRoutes } from './routes/progress.js'; import { gamificationRoutes } from './routes/gamification.js'; import { paymentRoutes } from './routes/payment.js'; +import { appApiRoutes } from './routes/app-api.js'; import { adminRoutes } from './routes/admin/index.js'; async function main(): Promise { @@ -65,6 +66,7 @@ async function main(): Promise { app.register(progressRoutes, { prefix: '/v1' }); app.register(gamificationRoutes, { prefix: '/v1' }); app.register(paymentRoutes, { prefix: '/v1' }); + app.register(appApiRoutes, { prefix: '/v1' }); // Admin routes: higher rate limit (100/min) app.register(adminRoutes, { prefix: '/v1/admin' }); diff --git a/src/routes/app-api.ts b/src/routes/app-api.ts new file mode 100644 index 0000000..f9d4aca --- /dev/null +++ b/src/routes/app-api.ts @@ -0,0 +1,173 @@ +import { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { getBootstrap } from '../services/app/bootstrap-service.js'; +import { getThemeTrackById, getThemeTracks } from '../services/learning/tracks-service.js'; +import { getNextChallenge, submitChallengeAnswer } from '../services/learning/challenge-service.js'; +import { + checkIn, + getProgressSummary, + protectStreak, + restoreDailyAttempts, + updateProgressPreferences, +} from '../services/learning/progress-summary-service.js'; +import { restoreHearts } from '../services/progress/progress-service.js'; +import { getClientLeaderboard, getClientLeaderboardMe } from '../services/learning/leaderboard-api-service.js'; +import { getShopBenefits } from '../services/shop/shop-service.js'; +import { getClientSubscription, verifyClientSubscription } from '../services/subscription/subscription-api-service.js'; + +const rewardSourceSchema = z.object({ + source: z.enum(['ad', 'check_in', 'subscription', 'admin_grant', 'debug']), +}); + +const answerSchema = z.object({ + challengeId: z.string().min(1), + questionId: z.string().min(1), + selectedOptionId: z.string().min(1), + timeMs: z.number().min(0), + comboCount: z.number().int().min(0).optional(), +}); + +const preferencesSchema = z.object({ + activeTrackId: z.string().min(1).max(50), +}); + +const leaderboardQuerySchema = z.object({ + scope: z.enum(['region', 'topic']).default('region'), + trackId: z.string().optional(), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +const subscriptionVerifySchema = z.object({ + platform: z.enum(['huawei', 'apple', 'google']), + purchaseToken: z.string().min(1), + productId: z.string().min(1), + tier: z.enum(['pro', 'proplus']), +}); + +function getUserId(request: { user: unknown }): string { + return (request.user as { userId: string }).userId; +} + +function validationError(message: string | undefined) { + return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message } }; +} + +export async function appApiRoutes(app: FastifyInstance): Promise { + app.get('/app/bootstrap', async (request) => { + const data = await getBootstrap(getUserId(request)); + return { success: true, data, error: null }; + }); + + app.get('/tracks', async (request) => { + const data = await getThemeTracks(getUserId(request)); + return { success: true, data, error: null }; + }); + + app.get('/tracks/:trackId', async (request) => { + const { trackId } = request.params as { trackId: string }; + const data = await getThemeTrackById(getUserId(request), trackId); + return { success: true, data, error: null }; + }); + + app.get('/challenges/next', async (request) => { + const parsed = z.object({ trackId: z.string().min(1) }).safeParse(request.query); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await getNextChallenge(getUserId(request), parsed.data.trackId); + return { success: true, data, error: null }; + }); + + app.post('/challenges/answer', async (request) => { + const parsed = answerSchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await submitChallengeAnswer( + getUserId(request), + parsed.data.questionId, + parsed.data.selectedOptionId, + parsed.data.timeMs, + parsed.data.comboCount, + ); + return { success: true, data: { ...data, challengeId: parsed.data.challengeId }, error: null }; + }); + + app.get('/progress/summary', async (request) => { + const data = await getProgressSummary(getUserId(request)); + return { success: true, data, error: null }; + }); + + app.patch('/progress/preferences', async (request) => { + const parsed = preferencesSchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await updateProgressPreferences(getUserId(request), parsed.data.activeTrackId); + return { success: true, data, error: null }; + }); + + app.post('/progress/check-in', async (request) => { + const data = await checkIn(getUserId(request)); + return { success: true, data, error: null }; + }); + + app.post('/rewards/hearts/restore', async (request) => { + const parsed = rewardSourceSchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await restoreHearts(getUserId(request), 'ad'); + return { success: true, data, error: null }; + }); + + app.post('/rewards/attempts/restore', async (request) => { + const parsed = rewardSourceSchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await restoreDailyAttempts(getUserId(request), parsed.data.source); + return { success: true, data, error: null }; + }); + + app.post('/rewards/streak/protect', async (request) => { + const parsed = rewardSourceSchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await protectStreak(getUserId(request), parsed.data.source); + return { success: true, data, error: null }; + }); + + app.get('/leaderboards', async (request) => { + const parsed = leaderboardQuerySchema.safeParse(request.query); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await getClientLeaderboard( + getUserId(request), + parsed.data.scope, + parsed.data.trackId, + parsed.data.page, + parsed.data.limit, + ); + return { success: true, data: data.items, pagination: data.pagination, error: null }; + }); + + app.get('/leaderboards/me', async (request) => { + const parsed = leaderboardQuerySchema.safeParse(request.query); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await getClientLeaderboardMe(getUserId(request), parsed.data.scope, parsed.data.trackId); + return { success: true, data, error: null }; + }); + + app.get('/shop', async () => { + const data = await getShopBenefits(); + return { success: true, data, error: null }; + }); + + app.get('/subscription', async (request) => { + const data = await getClientSubscription(getUserId(request)); + return { success: true, data, error: null }; + }); + + app.post('/subscription/verify', async (request) => { + const parsed = subscriptionVerifySchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await verifyClientSubscription( + getUserId(request), + parsed.data.platform, + parsed.data.purchaseToken, + parsed.data.productId, + parsed.data.tier, + ); + return { success: true, data, error: null }; + }); +} diff --git a/src/services/app/bootstrap-service.ts b/src/services/app/bootstrap-service.ts new file mode 100644 index 0000000..e7e8d39 --- /dev/null +++ b/src/services/app/bootstrap-service.ts @@ -0,0 +1,46 @@ +import { db } from '../../db/client.js'; +import { users } from '../../db/schema.js'; +import { eq } from 'drizzle-orm'; +import { getProgressSummary, getLevelInfo } from '../learning/progress-summary-service.js'; +import { getThemeTracks } from '../learning/tracks-service.js'; +import { getShopBenefits } from '../shop/shop-service.js'; +import { getClientSubscription } from '../subscription/subscription-api-service.js'; +import type { BootstrapDto, SubscriptionTier } from '../../types/app-api.js'; + +export async function getBootstrap(userId: string): Promise { + const [user] = await db + .select({ + id: users.id, + nickname: users.nickname, + avatarUrl: users.avatarUrl, + tier: users.tier, + xpTotal: users.xpTotal, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + const [progress, tracks, shopBenefits, subscription] = await Promise.all([ + getProgressSummary(userId), + getThemeTracks(userId), + getShopBenefits(), + getClientSubscription(userId), + ]); + + const xp = user?.xpTotal ?? progress.xp; + const level = getLevelInfo(xp).level; + + return { + user: { + id: user?.id ?? userId, + nickname: user?.nickname ?? '知识探险家', + avatarUrl: user?.avatarUrl ?? null, + tier: (user?.tier ?? 'free') as SubscriptionTier, + level, + }, + progress, + tracks, + shopBenefits, + subscription, + }; +} diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts new file mode 100644 index 0000000..45de540 --- /dev/null +++ b/src/services/learning/challenge-service.ts @@ -0,0 +1,228 @@ +import { db } from '../../db/client.js'; +import { knowledgeCards, questions, skillTree, userChapterProgress, userProgress } from '../../db/schema.js'; +import { and, asc, eq, notInArray, sql } from 'drizzle-orm'; +import { v4 as uuid } from 'uuid'; +import { NotFoundError, ValidationError } from '../../utils/errors.js'; +import { addXp, BASE_XP, calculateXp } from '../progress/xp-service.js'; +import { deductHeart } from '../progress/hearts-service.js'; +import { updateStreak } from '../progress/streak-service.js'; +import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js'; +import { getTrackCategory } from './tracks-service.js'; +import type { AnswerResultDto, ChallengeQuestionDto } from '../../types/app-api.js'; + +type QuestionRow = typeof questions.$inferSelect; +type ChapterRow = typeof skillTree.$inferSelect; + +interface OptionDto { + id: string; + text: string; + isCorrect: boolean; +} + +function getPrompt(stem: unknown): string { + if (stem && typeof stem === 'object' && 'text' in stem && typeof stem.text === 'string') { + return stem.text; + } + return String(stem ?? ''); +} + +function hash(value: string): number { + let result = 0; + for (let i = 0; i < value.length; i += 1) { + result = (result * 31 + value.charCodeAt(i)) >>> 0; + } + return result; +} + +function buildOptions(question: QuestionRow): readonly OptionDto[] { + const distractors = Array.isArray(question.distractors) ? question.distractors.filter((item): item is string => typeof item === 'string') : []; + const rawOptions = [ + { text: question.correctAnswer, isCorrect: true }, + ...distractors.slice(0, 2).map((text) => ({ text, isCorrect: false })), + ]; + + const sorted = [...rawOptions].sort((a, b) => hash(`${question.id}:${a.text}`) - hash(`${question.id}:${b.text}`)); + return sorted.map((option, index) => ({ + id: String.fromCharCode(97 + index), + text: option.text, + isCorrect: option.isCorrect, + })); +} + +function toChallengeDto(trackId: string, chapter: ChapterRow, question: QuestionRow): ChallengeQuestionDto { + return { + challengeId: question.id, + trackId, + nodeId: chapter.id, + question: { + id: question.id, + prompt: getPrompt(question.stem), + options: buildOptions(question).map(({ id, text }) => ({ id, text })), + }, + }; +} + +async function getCurrentChapter(userId: string, categoryId: string): Promise { + const chapters = await db + .select() + .from(skillTree) + .where(eq(skillTree.categoryId, categoryId)) + .orderBy(asc(skillTree.sortOrder)); + + if (chapters.length === 0) return null; + + const progress = await db + .select() + .from(userChapterProgress) + .where(eq(userChapterProgress.userId, userId)); + + const progressMap = new Map(progress.map((item) => [item.chapterId, item.status])); + return chapters.find((chapter) => progressMap.get(chapter.id) === 'unlocked') + ?? chapters.find((chapter) => progressMap.get(chapter.id) !== 'passed' && progressMap.get(chapter.id) !== 'perfect') + ?? chapters[0]!; +} + +async function getQuestionForChapter(userId: string, chapter: ChapterRow): Promise { + const answered = await db + .select({ questionId: userProgress.questionId }) + .from(userProgress) + .where(eq(userProgress.userId, userId)); + const answeredIds = answered.map((item) => item.questionId); + + const conditions = [ + eq(questions.categoryId, chapter.categoryId), + eq(questions.status, 'published'), + ]; + + const available = answeredIds.length > 0 + ? await db.select().from(questions).where(and(...conditions, notInArray(questions.id, answeredIds))).limit(20) + : await db.select().from(questions).where(and(...conditions)).limit(20); + + return available[0] ?? null; +} + +async function getCorrectAnswersToday(userId: string): Promise { + const today = new Date().toISOString().slice(0, 10); + const rows = await db + .select({ id: userProgress.id }) + .from(userProgress) + .where(and( + eq(userProgress.userId, userId), + eq(userProgress.correct, 1), + sql`DATE(${userProgress.answeredAt}) = ${today}`, + )); + return rows.length; +} + +async function getKnowledgeCard(question: QuestionRow): Promise { + const [card] = await db + .select() + .from(knowledgeCards) + .where(eq(knowledgeCards.questionId, question.id)) + .limit(1); + + if (card) { + return { + id: card.id, + title: card.summary, + summary: card.summary, + fact: card.deepDive ?? `正确答案是:${question.correctAnswer}`, + }; + } + + return { + id: `fallback-${question.id}`, + title: '知识点回顾', + summary: `这道题的正确答案是:${question.correctAnswer}`, + fact: '继续观察题干中的关键词,可以更快定位答案。', + }; +} + +export async function getNextChallenge(userId: string, trackId: string): Promise { + const category = await getTrackCategory(trackId); + if (!category || category.status !== 'active') { + throw new NotFoundError('Track'); + } + + const chapter = await getCurrentChapter(userId, category.id); + if (!chapter) return null; + + const question = await getQuestionForChapter(userId, chapter); + if (!question) return null; + + return toChallengeDto(category.slug || category.id, chapter, question); +} + +export async function submitChallengeAnswer( + userId: string, + questionId: string, + selectedOptionId: string, + timeMs: number, + comboCount = 0, +): Promise { + const [question] = await db.select().from(questions).where(eq(questions.id, questionId)).limit(1); + if (!question) throw new NotFoundError('Question'); + + const options = buildOptions(question); + const selected = options.find((option) => option.id === selectedOptionId); + if (!selected) throw new ValidationError('Invalid selectedOptionId'); + + const correct = selected.isCorrect; + const correctOptionId = options.find((option) => option.isCorrect)?.id ?? 'a'; + + await db.insert(userProgress).values({ + id: uuid(), + userId, + questionId, + correct: correct ? 1 : 0, + timeMs, + }); + + await db + .update(questions) + .set({ + stats: sql`JSON_SET( + COALESCE(stats, '{}'), + '$.timesAnswered', COALESCE(JSON_EXTRACT(stats, '$.timesAnswered'), 0) + 1, + '$.correctRate', ROUND( + (COALESCE(JSON_EXTRACT(stats, '$.timesAnswered'), 0) * COALESCE(JSON_EXTRACT(stats, '$.correctRate'), 0) + ${correct ? 1 : 0}) + / (COALESCE(JSON_EXTRACT(stats, '$.timesAnswered'), 0) + 1), + 4 + ), + '$.avgTimeMs', ROUND( + (COALESCE(JSON_EXTRACT(stats, '$.timesAnswered'), 0) * COALESCE(JSON_EXTRACT(stats, '$.avgTimeMs'), 0) + ${timeMs}) + / (COALESCE(JSON_EXTRACT(stats, '$.timesAnswered'), 0) + 1) + ) + )`, + }) + .where(eq(questions.id, questionId)); + + let xpDelta = 0; + if (correct) { + xpDelta = calculateXp(BASE_XP, comboCount); + await addXp(userId, xpDelta); + await updateStreak(userId, await getCorrectAnswersToday(userId)); + } else { + await deductHeart(userId); + await deductDailyAttempt(userId); + } + + const [progress, knowledgeCard] = await Promise.all([ + getProgressSummary(userId), + getKnowledgeCard(question), + ]); + + return { + answerState: correct ? 'correct' : 'wrong', + correctOptionId, + xpDelta, + progress: { + hearts: progress.hearts, + dailyAttemptsLeft: progress.dailyAttemptsLeft, + xp: progress.xp, + streakDays: progress.streakDays, + }, + knowledgeCard, + rewards: correct && xpDelta > 0 ? [{ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }] : [], + }; +} diff --git a/src/services/learning/leaderboard-api-service.ts b/src/services/learning/leaderboard-api-service.ts new file mode 100644 index 0000000..a88c44f --- /dev/null +++ b/src/services/learning/leaderboard-api-service.ts @@ -0,0 +1,64 @@ +import { getLeaderboard, getUserRank } from '../gamification/leaderboard-service.js'; +import type { LeaderboardEntryDto, LeaderboardScope } from '../../types/app-api.js'; +import { db } from '../../db/client.js'; +import { users } from '../../db/schema.js'; +import { eq } from 'drizzle-orm'; + +function getBadge(rank: number): string { + if (rank === 1) return '榜首'; + if (rank <= 3) return '前三'; + if (rank <= 10) return '冲榜中'; + return '继续加油'; +} + +export async function getClientLeaderboard( + userId: string, + _scope: LeaderboardScope, + _trackId: string | undefined, + page: number, + limit: number, +): Promise<{ items: LeaderboardEntryDto[]; pagination: { total: number; page: number; limit: number } }> { + const data = await getLeaderboard(undefined, page, limit); + return { + items: data.items.map((entry) => ({ + rank: entry.rank, + userId: entry.userId, + displayName: entry.userId === userId ? '你' : (entry.nickname ?? '知识探险家'), + avatarUrl: entry.avatarUrl, + xp: entry.xpTotal, + badge: getBadge(entry.rank), + isMe: entry.userId === userId, + })), + pagination: data.pagination, + }; +} + +export async function getClientLeaderboardMe( + userId: string, + _scope: LeaderboardScope, + _trackId: string | undefined, +): Promise { + const [rank, user] = await Promise.all([ + getUserRank(userId), + db + .select({ + nickname: users.nickname, + avatarUrl: users.avatarUrl, + xpTotal: users.xpTotal, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1), + ]); + if (!rank) return null; + + return { + rank: rank.rank, + userId, + displayName: user[0]?.nickname ?? '你', + avatarUrl: user[0]?.avatarUrl ?? null, + xp: user[0]?.xpTotal ?? 0, + badge: getBadge(rank.rank), + isMe: true, + }; +} diff --git a/src/services/learning/progress-summary-service.ts b/src/services/learning/progress-summary-service.ts new file mode 100644 index 0000000..88ed247 --- /dev/null +++ b/src/services/learning/progress-summary-service.ts @@ -0,0 +1,182 @@ +import { db } from '../../db/client.js'; +import { users } from '../../db/schema.js'; +import { eq, sql } from 'drizzle-orm'; +import { getHearts } from '../progress/hearts-service.js'; +import { calculateStreak, freezeStreak } from '../progress/streak-service.js'; +import { getSubscriptionStatus } from '../payment/subscription-service.js'; +import type { ProgressSummaryDto, RewardSource } from '../../types/app-api.js'; + +export const FREE_DAILY_ATTEMPTS = 5; +export const PRO_DAILY_ATTEMPTS = 10; +export const PROPLUS_DAILY_ATTEMPTS = 20; +const HEART_RESTORE_MS = 30 * 60 * 1000; +const XP_PER_LEVEL = 400; + +type UserTier = 'free' | 'pro' | 'proplus'; + +interface ResourceUser { + id: string; + tier: UserTier | null; + xpTotal: number | null; + activeTrackId: string | null; + dailyAttemptsLeft: number | null; + dailyAttemptsDate: Date | string | null; + checkInDays: number | null; + lastCheckInDate: Date | string | null; + streakProtectedUntil: Date | string | null; +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function tomorrowIso(): string { + const date = new Date(); + date.setUTCHours(24, 0, 0, 0); + return date.toISOString(); +} + +function toDateString(value: Date | string | null): string | null { + if (!value) return null; + return typeof value === 'string' ? value.slice(0, 10) : value.toISOString().slice(0, 10); +} + +function toIso(value: Date | string | null): string | null { + if (!value) return null; + return typeof value === 'string' ? new Date(value).toISOString() : value.toISOString(); +} + +export function getDailyAttemptsMax(tier: string | null | undefined): number { + if (tier === 'proplus') return PROPLUS_DAILY_ATTEMPTS; + if (tier === 'pro') return PRO_DAILY_ATTEMPTS; + return FREE_DAILY_ATTEMPTS; +} + +export function getLevelInfo(xp: number): { level: number; xpToNextLevel: number } { + const level = Math.floor(Math.max(0, xp) / XP_PER_LEVEL) + 1; + const nextLevelXp = level * XP_PER_LEVEL; + return { level, xpToNextLevel: Math.max(0, nextLevelXp - xp) }; +} + +export function getNextHeartRestoreAt(lastRestore: string | null, hearts: number, maxHearts: number): string | null { + if (!lastRestore || hearts >= maxHearts) return null; + return new Date(new Date(lastRestore).getTime() + HEART_RESTORE_MS).toISOString(); +} + +async function getResourceUser(userId: string): Promise { + const [user] = await db + .select({ + id: users.id, + tier: users.tier, + xpTotal: users.xpTotal, + activeTrackId: users.activeTrackId, + dailyAttemptsLeft: users.dailyAttemptsLeft, + dailyAttemptsDate: users.dailyAttemptsDate, + checkInDays: users.checkInDays, + lastCheckInDate: users.lastCheckInDate, + streakProtectedUntil: users.streakProtectedUntil, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user ?? null; +} + +export async function getDailyAttempts(userId: string): Promise<{ left: number; max: number; nextResetAt: string }> { + const user = await getResourceUser(userId); + const max = getDailyAttemptsMax(user?.tier); + if (!user) return { left: max, max, nextResetAt: tomorrowIso() }; + + if (toDateString(user.dailyAttemptsDate) !== today()) { + await db + .update(users) + .set({ dailyAttemptsLeft: max, dailyAttemptsDate: sql`CAST(${today()} AS DATE)` }) + .where(eq(users.id, userId)); + return { left: max, max, nextResetAt: tomorrowIso() }; + } + + return { + left: Math.min(user.dailyAttemptsLeft ?? max, max), + max, + nextResetAt: tomorrowIso(), + }; +} + +export async function deductDailyAttempt(userId: string): Promise { + const attempts = await getDailyAttempts(userId); + const next = Math.max(0, attempts.left - 1); + await db + .update(users) + .set({ dailyAttemptsLeft: next, dailyAttemptsDate: sql`CAST(${today()} AS DATE)` }) + .where(eq(users.id, userId)); + return next; +} + +export async function restoreDailyAttempts(userId: string, _source: RewardSource): Promise<{ dailyAttemptsLeft: number; dailyAttemptsMax: number }> { + const attempts = await getDailyAttempts(userId); + await db + .update(users) + .set({ dailyAttemptsLeft: attempts.max, dailyAttemptsDate: sql`CAST(${today()} AS DATE)` }) + .where(eq(users.id, userId)); + return { dailyAttemptsLeft: attempts.max, dailyAttemptsMax: attempts.max }; +} + +export async function updateProgressPreferences(userId: string, activeTrackId: string): Promise { + await db.update(users).set({ activeTrackId }).where(eq(users.id, userId)); + return getProgressSummary(userId); +} + +export async function checkIn(userId: string): Promise { + const user = await getResourceUser(userId); + const alreadyCheckedIn = toDateString(user?.lastCheckInDate ?? null) === today(); + if (!alreadyCheckedIn) { + await db + .update(users) + .set({ + checkInDays: sql`COALESCE(check_in_days, 0) + 1`, + lastCheckInDate: sql`CAST(${today()} AS DATE)`, + }) + .where(eq(users.id, userId)); + } + return getProgressSummary(userId); +} + +export async function protectStreak(userId: string, _source: RewardSource): Promise { + const protectedUntil = new Date(); + protectedUntil.setUTCDate(protectedUntil.getUTCDate() + 1); + await db.update(users).set({ streakProtectedUntil: protectedUntil }).where(eq(users.id, userId)); + await freezeStreak(userId); + return getProgressSummary(userId); +} + +export async function getProgressSummary(userId: string): Promise { + const [user, hearts, streak, subscription, attempts] = await Promise.all([ + getResourceUser(userId), + getHearts(userId), + calculateStreak(userId), + getSubscriptionStatus(userId), + getDailyAttempts(userId), + ]); + + const xp = user?.xpTotal ?? 0; + const level = getLevelInfo(xp); + const isSubscribed = subscription.status === 'active' && subscription.tier !== 'free'; + + return { + hearts: hearts.remaining, + maxHearts: hearts.max, + nextHeartRestoreAt: getNextHeartRestoreAt(hearts.lastRestore, hearts.remaining, hearts.max), + dailyAttemptsLeft: attempts.left, + dailyAttemptsMax: attempts.max, + nextAttemptResetAt: attempts.nextResetAt, + xp, + level: level.level, + xpToNextLevel: level.xpToNextLevel, + streakDays: streak.days, + checkInDays: user?.checkInDays ?? 0, + streakProtectedUntil: toIso(user?.streakProtectedUntil ?? null), + activeTrackId: user?.activeTrackId ?? null, + isSubscribed, + }; +} diff --git a/src/services/learning/tracks-service.ts b/src/services/learning/tracks-service.ts new file mode 100644 index 0000000..2f1fded --- /dev/null +++ b/src/services/learning/tracks-service.ts @@ -0,0 +1,114 @@ +import { db } from '../../db/client.js'; +import { categories, skillTree, userChapterProgress } from '../../db/schema.js'; +import { asc, eq, sql } from 'drizzle-orm'; +import type { NodeStatus, ThemeNodeDto, ThemeTrackDto } from '../../types/app-api.js'; +import { BASE_XP } from '../progress/xp-service.js'; + +const DEFAULT_TRACK_ICON = '📚'; +const TRACK_ICONS: Readonly> = Object.freeze({ + history: '🏛', + drama: '🎭', + crosstalk: '🎙', + geography: '🗺', + general: '💡', +}); + +type CategoryRow = typeof categories.$inferSelect; +type ChapterRow = typeof skillTree.$inferSelect; +type ChapterProgressRow = typeof userChapterProgress.$inferSelect; + +function getIcon(category: CategoryRow): string { + return TRACK_ICONS[category.slug] ?? TRACK_ICONS[category.id] ?? DEFAULT_TRACK_ICON; +} + +function mapNodeStatus(status: ChapterProgressRow['status'] | undefined, hasAnyCurrent: boolean): NodeStatus { + if (status === 'passed' || status === 'perfect') return 'done'; + if (status === 'unlocked') return 'current'; + if (!status && !hasAnyCurrent) return 'current'; + return 'locked'; +} + +function toNode(chapter: ChapterRow, progress: ChapterProgressRow | undefined, hasAnyCurrent: boolean): ThemeNodeDto { + const questionCount = chapter.questionsRequired ?? 0; + return { + id: chapter.id, + title: chapter.title, + status: mapNodeStatus(progress?.status, hasAnyCurrent), + reward: `+${BASE_XP * questionCount} XP`, + questionCount, + }; +} + +function calculateTrackProgress(nodes: readonly ThemeNodeDto[]): number { + if (nodes.length === 0) return 0; + const done = nodes.filter((node) => node.status === 'done').length; + return Math.round((done / nodes.length) * 100); +} + +async function getProgressMap(userId: string): Promise> { + const progress = await db + .select() + .from(userChapterProgress) + .where(eq(userChapterProgress.userId, userId)); + return new Map(progress.map((item) => [item.chapterId, item])); +} + +export async function getThemeTracks(userId: string): Promise { + const [activeCategories, chapters, progressMap] = await Promise.all([ + db.select().from(categories).where(eq(categories.status, 'active')).orderBy(asc(categories.sortOrder)), + db.select().from(skillTree).orderBy(asc(skillTree.sortOrder)), + getProgressMap(userId), + ]); + + return activeCategories.map((category) => { + const categoryChapters = chapters.filter((chapter) => chapter.categoryId === category.id); + const hasAnyCurrent = categoryChapters.some((chapter) => progressMap.get(chapter.id)?.status === 'unlocked'); + const nodes = categoryChapters.map((chapter) => toNode(chapter, progressMap.get(chapter.id), hasAnyCurrent)); + return { + id: category.slug || category.id, + name: category.name, + icon: getIcon(category), + progress: calculateTrackProgress(nodes), + nodes, + }; + }); +} + +export async function getThemeTrackById(userId: string, trackId: string): Promise { + const [category] = await db + .select() + .from(categories) + .where(sql`${categories.id} = ${trackId} OR ${categories.slug} = ${trackId}`) + .limit(1); + + if (!category || category.status !== 'active') return null; + + const [chapters, progressMap] = await Promise.all([ + db + .select() + .from(skillTree) + .where(eq(skillTree.categoryId, category.id)) + .orderBy(asc(skillTree.sortOrder)), + getProgressMap(userId), + ]); + + const hasAnyCurrent = chapters.some((chapter) => progressMap.get(chapter.id)?.status === 'unlocked'); + const nodes = chapters.map((chapter) => toNode(chapter, progressMap.get(chapter.id), hasAnyCurrent)); + + return { + id: category.slug || category.id, + name: category.name, + icon: getIcon(category), + progress: calculateTrackProgress(nodes), + nodes, + }; +} + +export async function getTrackCategory(trackId: string): Promise { + const [category] = await db + .select() + .from(categories) + .where(sql`${categories.id} = ${trackId} OR ${categories.slug} = ${trackId}`) + .limit(1); + return category ?? null; +} diff --git a/src/services/shop/shop-service.ts b/src/services/shop/shop-service.ts new file mode 100644 index 0000000..5670b7f --- /dev/null +++ b/src/services/shop/shop-service.ts @@ -0,0 +1,40 @@ +import type { ShopBenefitDto } from '../../types/app-api.js'; + +const SHOP_BENEFITS: readonly ShopBenefitDto[] = Object.freeze([ + { + id: 'restore-hearts', + type: 'hearts', + title: '恢复满血', + description: '血量不足时继续挑战', + enabled: true, + requiresAd: true, + }, + { + id: 'restore-attempts', + type: 'attempts', + title: '恢复挑战次数', + description: '今日次数用完后继续闯关', + enabled: true, + requiresAd: true, + }, + { + id: 'protect-streak', + type: 'streak', + title: '连续学习保护', + description: '忙碌时保护连续学习天数', + enabled: true, + requiresAd: true, + }, + { + id: 'duoqi-plus', + type: 'subscription', + title: '多奇 Plus', + description: '更多挑战次数和学习权益', + enabled: true, + requiresAd: false, + }, +]); + +export async function getShopBenefits(): Promise { + return SHOP_BENEFITS; +} diff --git a/src/services/subscription/subscription-api-service.ts b/src/services/subscription/subscription-api-service.ts new file mode 100644 index 0000000..72b209b --- /dev/null +++ b/src/services/subscription/subscription-api-service.ts @@ -0,0 +1,42 @@ +import { AppError } from '../../utils/errors.js'; +import { verifyReceipt } from '../payment/huawei-iap.js'; +import { activateSubscription, getSubscriptionStatus } from '../payment/subscription-service.js'; +import type { SubscriptionDto, SubscriptionPlatform, SubscriptionTier } from '../../types/app-api.js'; + +function toSubscriptionDto(status: Awaited>): SubscriptionDto { + return { + status: status.status as SubscriptionDto['status'], + tier: status.tier as SubscriptionTier, + expiresAt: status.expiresAt, + autoRenew: status.autoRenew, + }; +} + +export async function getClientSubscription(userId: string): Promise { + return toSubscriptionDto(await getSubscriptionStatus(userId)); +} + +export async function verifyClientSubscription( + userId: string, + platform: SubscriptionPlatform, + purchaseToken: string, + productId: string, + tier: Exclude, +): Promise { + if (platform !== 'huawei') { + throw new AppError(`${platform} subscription verification is not supported yet`, 400, 'UNSUPPORTED_PLATFORM'); + } + + const result = await verifyReceipt(purchaseToken); + if (!result.valid) { + throw new AppError('Purchase verification failed', 400, 'INVALID_RECEIPT'); + } + + const expiresAt = result.expiryTime + ? new Date(result.expiryTime) + : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + void productId; + await activateSubscription(userId, platform, purchaseToken, tier, expiresAt); + return getClientSubscription(userId); +} diff --git a/src/types/app-api.ts b/src/types/app-api.ts new file mode 100644 index 0000000..0b9ffdd --- /dev/null +++ b/src/types/app-api.ts @@ -0,0 +1,118 @@ +export type SubscriptionTier = 'free' | 'pro' | 'proplus'; +export type SubscriptionStatus = 'none' | 'active' | 'expired' | 'cancelled'; +export type NodeStatus = 'done' | 'current' | 'locked' | 'chest'; +export type LeaderboardScope = 'region' | 'topic'; +export type RewardSource = 'ad' | 'check_in' | 'subscription' | 'admin_grant' | 'debug'; +export type SubscriptionPlatform = 'huawei' | 'apple' | 'google'; + +export interface UserBriefDto { + id: string; + nickname: string; + avatarUrl: string | null; + tier: SubscriptionTier; + level: number; +} + +export interface SubscriptionDto { + status: SubscriptionStatus; + tier: SubscriptionTier; + expiresAt: string | null; + autoRenew?: boolean; +} + +export interface ProgressSummaryDto { + hearts: number; + maxHearts: number; + nextHeartRestoreAt: string | null; + dailyAttemptsLeft: number; + dailyAttemptsMax: number; + nextAttemptResetAt: string | null; + xp: number; + level: number; + xpToNextLevel: number; + streakDays: number; + checkInDays: number; + streakProtectedUntil: string | null; + activeTrackId: string | null; + isSubscribed: boolean; +} + +export interface ThemeNodeDto { + id: string; + title: string; + status: NodeStatus; + reward: string; + questionCount: number; +} + +export interface ThemeTrackDto { + id: string; + name: string; + icon: string; + progress: number; + nodes: ThemeNodeDto[]; +} + +export interface ShopBenefitDto { + id: string; + type: 'hearts' | 'attempts' | 'streak' | 'subscription'; + title: string; + description: string; + enabled: boolean; + requiresAd: boolean; +} + +export interface BootstrapDto { + user: UserBriefDto; + progress: ProgressSummaryDto; + tracks: ThemeTrackDto[]; + shopBenefits: readonly ShopBenefitDto[]; + subscription: SubscriptionDto; +} + +export interface ChallengeQuestionDto { + challengeId: string; + trackId: string; + nodeId: string; + question: { + id: string; + prompt: string; + options: ReadonlyArray<{ id: string; text: string }>; + }; +} + +export interface AnswerRequestDto { + challengeId: string; + questionId: string; + selectedOptionId: string; + timeMs: number; +} + +export interface AnswerResultDto { + answerState: 'correct' | 'wrong'; + correctOptionId: string; + xpDelta: number; + progress: { + hearts: number; + dailyAttemptsLeft: number; + xp: number; + streakDays: number; + }; + knowledgeCard: { + id: string; + title: string; + summary: string; + fact: string; + }; + rewards: ReadonlyArray<{ type: string; amount?: number; title?: string }>; +} + +export interface LeaderboardEntryDto { + rank: number; + userId: string; + displayName: string; + avatarUrl: string | null; + xp: number; + badge: string; + isMe: boolean; +}