Phase 1b — Core Features: - Huawei ID Kit login (token exchange + user info) with guest mode - Quiz engine: randomized questions, distractor shuffling, answer verification - XP service with combo bonuses (3/5/10-hit streaks), daily reset - Streak service: >=3 correct/day, freeze, UTC date handling - Hearts service: 5/day, 30min auto-restore, Pro unlimited - 50 quiz questions across 3 categories (history/drama/crosstalk) - 13 skill tree chapters with linear progression - Idempotent seed import script (categories → skill tree → questions) - 7 admin CRUD services (questions, categories, knowledge cards, skill tree, users, stats, feedback) with Zod validation - All routes use Zod schema validation, /auth/me endpoint Phase 1c — Commercialization: - Leaderboard with live XP ranking, 10 tiers, weekly settlement - Achievement system with 15 seed achievements and condition checking - Huawei IAP receipt verification + subscription management - Differentiated rate limiting (auth 10/min, quiz 60/min) - Admin audit logging middleware Infrastructure: - Vitest test framework with DB mock utilities (19 tests passing) - 12 DB tables (5 new: question_ratings, user_feedback, achievements, user_achievements, leaderboard_snapshots, subscriptions, admin_audit_log) - TypeScript strict mode: zero errors
92 lines
2.4 KiB
TypeScript
92 lines
2.4 KiB
TypeScript
import { db } from '../../db/client.js';
|
|
import { users } from '../../db/schema.js';
|
|
import { eq, sql } from 'drizzle-orm';
|
|
|
|
/** Combo bonus tiers: minimum combo count → bonus XP */
|
|
const COMBO_BONUSES: ReadonlyArray<{ minCombo: number; bonus: number }> = [
|
|
{ minCombo: 10, bonus: 20 },
|
|
{ minCombo: 5, bonus: 10 },
|
|
{ minCombo: 3, bonus: 5 },
|
|
];
|
|
|
|
const BASE_XP = 10;
|
|
const DEFAULT_DAILY_GOAL = 50;
|
|
|
|
function toDateString(value: Date | string | null): string | null {
|
|
if (!value) return null;
|
|
if (typeof value === 'string') return value.slice(0, 10);
|
|
return value.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export interface DailyXpStatus {
|
|
earned: number;
|
|
goal: number;
|
|
date: string;
|
|
}
|
|
|
|
/**
|
|
* Calculate total XP for a correct answer, including combo bonus.
|
|
* Combo bonus is cumulative: 3-combo = +5, 5-combo = +10, 10-combo = +20.
|
|
*/
|
|
export function calculateXp(baseXp: number, comboCount: number): number {
|
|
let bonus = 0;
|
|
for (const tier of COMBO_BONUSES) {
|
|
if (comboCount >= tier.minCombo) {
|
|
bonus = tier.bonus;
|
|
break;
|
|
}
|
|
}
|
|
return baseXp + bonus;
|
|
}
|
|
|
|
/**
|
|
* Add XP to a user. Handles daily XP reset if the date has changed.
|
|
* Uses atomic SQL update to prevent race conditions.
|
|
*/
|
|
export async function addXp(userId: string, amount: number): Promise<void> {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
|
|
// Atomically update total XP and handle daily reset
|
|
await db
|
|
.update(users)
|
|
.set({
|
|
xpTotal: sql`COALESCE(xp_total, 0) + ${amount}`,
|
|
dailyXpEarned: sql`CASE
|
|
WHEN COALESCE(daily_xp_date, '') = ${today}
|
|
THEN COALESCE(daily_xp_earned, 0) + ${amount}
|
|
ELSE ${amount}
|
|
END`,
|
|
dailyXpDate: sql`CAST(${today} AS DATE)`,
|
|
})
|
|
.where(eq(users.id, userId));
|
|
}
|
|
|
|
/**
|
|
* Get the user's daily XP status.
|
|
*/
|
|
export async function getDailyXpStatus(userId: string): Promise<DailyXpStatus> {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const [user] = await db
|
|
.select({
|
|
dailyXpEarned: users.dailyXpEarned,
|
|
dailyXpDate: users.dailyXpDate,
|
|
dailyXpGoal: users.dailyXpGoal,
|
|
})
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1);
|
|
|
|
if (!user) {
|
|
return { earned: 0, goal: DEFAULT_DAILY_GOAL, date: today };
|
|
}
|
|
|
|
const isToday = toDateString(user.dailyXpDate) === today;
|
|
return {
|
|
earned: isToday ? (user.dailyXpEarned ?? 0) : 0,
|
|
goal: user.dailyXpGoal ?? DEFAULT_DAILY_GOAL,
|
|
date: today,
|
|
};
|
|
}
|
|
|
|
export { BASE_XP };
|