import cors from '@fastify/cors'; import Fastify from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts'; import { initializeDatabase, pool } from './db.ts'; import { cleanLocale, createConfig, createDailyChecklistItem, createHabitat, createItem, createLanguage, createPokemon, createRecipe, deleteConfig, deleteDailyChecklistItem, deleteHabitat, deleteItem, deleteLanguage, deletePokemon, deleteRecipe, getHabitat, getItem, getOptions, getPokemon, getRecipe, isConfigType, listConfig, listDailyChecklistItems, listHabitats, listItems, listLanguages, listPokemon, listRecipes, reorderConfig, reorderDailyChecklistItems, reorderHabitats, reorderItems, reorderLanguages, reorderPokemon, reorderRecipes, updateConfig, updateDailyChecklistItem, updateHabitat, updateItem, updateLanguage, updatePokemon, updateRecipe } from './queries.ts'; const app = Fastify({ logger: true }); await app.register(cors, { allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], origin: process.env.FRONTEND_ORIGIN ?? true }); app.setErrorHandler(async (error, _request, reply) => { const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number }; const locale = requestLocale(_request); if (pgError.code === '23503') { return reply.code(409).send({ message: serverMessage(locale, 'foreignKey') }); } if (pgError.code === '23505') { return reply.code(409).send({ message: serverMessage(locale, 'duplicate') }); } if (pgError.code === '23514') { return reply.code(400).send({ message: serverMessage(locale, 'invalidField') }); } if (pgError.statusCode && pgError.statusCode < 500) { return reply.code(pgError.statusCode).send({ message: pgError.message }); } app.log.error(error); return reply.code(500).send({ message: serverMessage(locale, 'serverError') }); }); app.get('/health', async () => ({ ok: true })); function getBearerToken(authorization: string | undefined): string | null { const [scheme, token] = authorization?.split(' ') ?? []; return scheme === 'Bearer' && token ? token : null; } function requestLocale(request: FastifyRequest): string { const query = request.query as Record; const queryLocale = Array.isArray(query.locale) ? query.locale[0] : query.locale; const headerLocale = request.headers['x-locale']; return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale)); } function serverMessage(locale: string, key: 'foreignKey' | 'duplicate' | 'invalidField' | 'serverError' | 'loginRequired' | 'verifyEmailFirst'): string { const messages = { en: { foreignKey: 'Referenced data does not exist or the record is currently in use', duplicate: 'A record with the same name or ID already exists', invalidField: 'Field value is invalid', serverError: 'Server error', loginRequired: 'Please log in first', verifyEmailFirst: 'Please complete email verification first' }, 'zh-CN': { foreignKey: '引用的数据不存在,或当前记录正在被使用', duplicate: '同名或相同 ID 的记录已存在', invalidField: '字段值不合法', serverError: '服务器错误', loginRequired: '请先登录', verifyEmailFirst: '请先完成邮箱验证' } }; return messages[locale as keyof typeof messages]?.[key] ?? messages.en[key]; } async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise { const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; const locale = requestLocale(request); if (!user) { reply.code(401).send({ message: serverMessage(locale, 'loginRequired') }); return null; } if (!user.emailVerified) { reply.code(403).send({ message: serverMessage(locale, 'verifyEmailFirst') }); return null; } return user; } app.post('/api/auth/register', async (request, reply) => reply.code(201).send(await registerUser(request.body as Record, requestLocale(request))) ); app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record, requestLocale(request))); app.post('/api/auth/login', async (request) => loginUser(request.body as Record, requestLocale(request))); app.get('/api/auth/me', async (request, reply) => { const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; if (!user) { return reply.code(401).send({ message: serverMessage(requestLocale(request), 'loginRequired') }); } return { user }; }); app.post('/api/auth/logout', async (request, reply) => { const token = getBearerToken(request.headers.authorization); if (token) { await logoutSession(token); } return reply.code(204).send(); }); app.get('/api/languages', async () => listLanguages()); app.get('/api/options', async (request) => getOptions(requestLocale(request))); app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request))); app.get('/api/pokemon', async (request) => listPokemon(request.query as Record, requestLocale(request)) ); app.get('/api/pokemon/:id', async (request, reply) => { const { id } = request.params as { id: string }; const pokemon = await getPokemon(Number(id), requestLocale(request)); if (!pokemon) { return reply.code(404).send({ message: 'Not found' }); } return pokemon; }); app.post('/api/pokemon', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reply.code(201).send(await createPokemon(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/pokemon/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { id } = request.params as { id: string }; const pokemon = await updatePokemon(Number(id), request.body as Record, user.id, requestLocale(request)); if (!pokemon) { return reply.code(404).send({ message: 'Not found' }); } return pokemon; }); app.delete('/api/pokemon/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { id } = request.params as { id: string }; const deleted = await deletePokemon(Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); app.get('/api/habitats', async (request) => listHabitats(requestLocale(request))); app.get('/api/habitats/:id', async (request, reply) => { const { id } = request.params as { id: string }; const habitat = await getHabitat(Number(id), requestLocale(request)); if (!habitat) { return reply.code(404).send({ message: 'Not found' }); } return habitat; }); app.post('/api/habitats', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reply.code(201).send(await createHabitat(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/habitats/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { id } = request.params as { id: string }; const habitat = await updateHabitat(Number(id), request.body as Record, user.id, requestLocale(request)); if (!habitat) { return reply.code(404).send({ message: 'Not found' }); } return habitat; }); app.delete('/api/habitats/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { id } = request.params as { id: string }; const deleted = await deleteHabitat(Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); app.get('/api/items', async (request) => listItems(request.query as Record, requestLocale(request)) ); app.get('/api/items/:id', async (request, reply) => { const { id } = request.params as { id: string }; const item = await getItem(Number(id), requestLocale(request)); if (!item) { return reply.code(404).send({ message: 'Not found' }); } return item; }); app.post('/api/items', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reply.code(201).send(await createItem(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/items/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { id } = request.params as { id: string }; const item = await updateItem(Number(id), request.body as Record, user.id, requestLocale(request)); if (!item) { return reply.code(404).send({ message: 'Not found' }); } return item; }); app.delete('/api/items/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { id } = request.params as { id: string }; const deleted = await deleteItem(Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); app.get('/api/recipes', async (request) => listRecipes(request.query as Record, requestLocale(request)) ); app.get('/api/recipes/:id', async (request, reply) => { const { id } = request.params as { id: string }; const recipe = await getRecipe(Number(id), requestLocale(request)); if (!recipe) { return reply.code(404).send({ message: 'Not found' }); } return recipe; }); app.post('/api/recipes', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reply.code(201).send(await createRecipe(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/recipes/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { id } = request.params as { id: string }; const recipe = await updateRecipe(Number(id), request.body as Record, user.id, requestLocale(request)); if (!recipe) { return reply.code(404).send({ message: 'Not found' }); } return recipe; }); app.delete('/api/recipes/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { id } = request.params as { id: string }; const deleted = await deleteRecipe(Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); app.post('/api/admin/daily-checklist', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reply .code(201) .send(await createDailyChecklistItem(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/admin/daily-checklist/order', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reorderDailyChecklistItems(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/daily-checklist/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { id } = request.params as { id: string }; const item = await updateDailyChecklistItem( Number(id), request.body as Record, user.id, requestLocale(request) ); return item ? item : reply.code(404).send({ message: 'Not found' }); }); app.delete('/api/admin/daily-checklist/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { id } = request.params as { id: string }; const deleted = await deleteDailyChecklistItem(Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); app.put('/api/admin/pokemon/order', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reorderPokemon(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/items/order', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reorderItems(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/recipes/order', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reorderRecipes(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/habitats/order', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reorderHabitats(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.get('/api/admin/languages', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? listLanguages(true) : undefined; }); app.post('/api/admin/languages', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reply.code(201).send(await createLanguage(request.body as Record)) : undefined; }); app.put('/api/admin/languages/order', async (request, reply) => { const user = await requireVerifiedUser(request, reply); return user ? reorderLanguages(request.body as Record) : undefined; }); app.put('/api/admin/languages/:code', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { code } = request.params as { code: string }; return updateLanguage(code, request.body as Record); }); app.delete('/api/admin/languages/:code', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { code } = request.params as { code: string }; const deleted = await deleteLanguage(code); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); app.get('/api/admin/config/:type', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { type } = request.params as { type: string }; if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } return listConfig(type, requestLocale(request)); }); app.post('/api/admin/config/:type', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { type } = request.params as { type: string }; if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } return reply .code(201) .send(await createConfig(type, request.body as Record, user.id, requestLocale(request))); }); app.put('/api/admin/config/:type/order', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { type } = request.params as { type: string }; if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } return reorderConfig(type, request.body as Record, user.id, requestLocale(request)); }); app.put('/api/admin/config/:type/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { type, id } = request.params as { type: string; id: string }; if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } const config = await updateConfig(type, Number(id), request.body as Record, user.id, requestLocale(request)); return config ? config : reply.code(404).send({ message: 'Not found' }); }); app.delete('/api/admin/config/:type/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { return; } const { type, id } = request.params as { type: string; id: string }; if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } const deleted = await deleteConfig(type, Number(id), user.id); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); const port = Number(process.env.BACKEND_PORT ?? 3001); try { await initializeDatabase(); await app.listen({ host: '0.0.0.0', port }); } catch (error) { app.log.error(error); await pool.end(); process.exit(1); }