208 lines
8.1 KiB
TypeScript
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 };
|
|
});
|
|
}
|