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
135 lines
3.3 KiB
TypeScript
135 lines
3.3 KiB
TypeScript
import { db } from '../../db/client.js';
|
|
import { users } from '../../db/schema.js';
|
|
import { eq, sql } from 'drizzle-orm';
|
|
|
|
export interface StreakInfo {
|
|
days: number;
|
|
lastDate: string | null;
|
|
frozen: boolean;
|
|
}
|
|
|
|
/** Minimum correct answers per day to count toward streak */
|
|
const STREAK_THRESHOLD = 3;
|
|
|
|
/**
|
|
* Normalize a date value (Date or string from mysql2) to 'YYYY-MM-DD' string.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Get the user's current streak info.
|
|
* All date comparisons use UTC date strings (YYYY-MM-DD).
|
|
*/
|
|
export async function calculateStreak(userId: string): Promise<StreakInfo> {
|
|
const [user] = await db
|
|
.select({
|
|
streakDays: users.streakDays,
|
|
streakLastDate: users.streakLastDate,
|
|
})
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1);
|
|
|
|
if (!user || !user.streakLastDate) {
|
|
return { days: 0, lastDate: null, frozen: false };
|
|
}
|
|
|
|
const today = todayUtc();
|
|
const yesterday = yesterdayUtc();
|
|
const lastDate = toDateString(user.streakLastDate);
|
|
|
|
if (lastDate === today) {
|
|
return { days: user.streakDays ?? 0, lastDate, frozen: false };
|
|
}
|
|
|
|
if (lastDate === yesterday) {
|
|
return { days: user.streakDays ?? 0, lastDate, frozen: false };
|
|
}
|
|
|
|
// Streak is broken
|
|
return { days: 0, lastDate, frozen: false };
|
|
}
|
|
|
|
/**
|
|
* Update the user's streak after answering questions.
|
|
*/
|
|
export async function updateStreak(userId: string, correctAnswersToday: number): Promise<StreakInfo> {
|
|
const today = todayUtc();
|
|
|
|
const [user] = await db
|
|
.select({
|
|
streakDays: users.streakDays,
|
|
streakLastDate: users.streakLastDate,
|
|
})
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1);
|
|
|
|
if (!user) {
|
|
return { days: 0, lastDate: null, frozen: false };
|
|
}
|
|
|
|
const lastDate = toDateString(user.streakLastDate);
|
|
|
|
// Already updated streak today
|
|
if (lastDate === today) {
|
|
return { days: user.streakDays ?? 0, lastDate: today, frozen: false };
|
|
}
|
|
|
|
// Check if threshold is met
|
|
if (correctAnswersToday < STREAK_THRESHOLD) {
|
|
return {
|
|
days: user.streakDays ?? 0,
|
|
lastDate,
|
|
frozen: false,
|
|
};
|
|
}
|
|
|
|
const yesterday = yesterdayUtc();
|
|
const isConsecutive = lastDate === yesterday;
|
|
const newDays = isConsecutive ? (user.streakDays ?? 0) + 1 : 1;
|
|
|
|
await db
|
|
.update(users)
|
|
.set({ streakDays: newDays, streakLastDate: sql`CAST(${today} AS DATE)` })
|
|
.where(eq(users.id, userId));
|
|
|
|
return { days: newDays, lastDate: today, frozen: false };
|
|
}
|
|
|
|
/**
|
|
* Freeze the streak (set last date to today without incrementing).
|
|
*/
|
|
export async function freezeStreak(userId: string): Promise<StreakInfo> {
|
|
const today = todayUtc();
|
|
|
|
await db
|
|
.update(users)
|
|
.set({ streakLastDate: sql`CAST(${today} AS DATE)` })
|
|
.where(eq(users.id, userId));
|
|
|
|
const [user] = await db
|
|
.select({ streakDays: users.streakDays })
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1);
|
|
|
|
return { days: user?.streakDays ?? 0, lastDate: today, frozen: true };
|
|
}
|
|
|
|
function todayUtc(): string {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
function yesterdayUtc(): string {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() - 1);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export { STREAK_THRESHOLD };
|