duoqi-api/src/routes/app-api.ts

208 lines
8.1 KiB
TypeScript

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<void> {
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 };
});
}