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 { getShopCatalog, purchaseShopProduct } from '../services/shop/shop-service.js'; import { getClientSubscription, verifyClientSubscription } from '../services/subscription/subscription-api-service.js'; import { useInventoryItem } from '../services/gamification/item-use-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(), submitRequestId: z.string().min(1).max(80).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']), }); const shopPurchaseSchema = z.object({ productId: z.enum(['hint-feather', 'heart-supply', 'double-xp-potion', 'streak-shield', 'mascot-outfit-starter']), clientRequestId: z.string().min(1).max(80), }); const useItemSchema = z.object({ itemId: z.enum(['streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather']), clientRequestId: z.string().min(1).max(80), questionId: z.string().min(1).optional(), }); 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.challengeId, parsed.data.questionId, parsed.data.selectedOptionId, parsed.data.timeMs, parsed.data.comboCount, parsed.data.submitRequestId, ); 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 getShopCatalog(); return { success: true, data, error: null }; }); app.post('/shop/purchase', async (request) => { const parsed = shopPurchaseSchema.safeParse(request.body); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); const data = await purchaseShopProduct(getUserId(request), parsed.data.productId, parsed.data.clientRequestId); return { success: true, data, error: null }; }); app.post('/inventory/items/use', async (request) => { const parsed = useItemSchema.safeParse(request.body); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); const data = await useInventoryItem({ userId: getUserId(request), itemId: parsed.data.itemId, clientRequestId: parsed.data.clientRequestId, questionId: parsed.data.questionId, }); 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 }; }); }