Add sort_order column to pokemon, items, recipes, habitats, and configs Implement drag-and-drop reordering in the admin interface Update API endpoints and database queries to respect the new sort order
527 lines
17 KiB
TypeScript
527 lines
17 KiB
TypeScript
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<string, string | string[] | undefined>;
|
|
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<AuthUser | null> {
|
|
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<string, unknown>, requestLocale(request)))
|
|
);
|
|
|
|
app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record<string, unknown>, requestLocale(request)));
|
|
|
|
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>, 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<string, string | string[] | undefined>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, string | string[] | undefined>, 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<string, unknown>, 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<string, unknown>, 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<string, string | string[] | undefined>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>,
|
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>)) : undefined;
|
|
});
|
|
|
|
app.put('/api/admin/languages/order', async (request, reply) => {
|
|
const user = await requireVerifiedUser(request, reply);
|
|
return user ? reorderLanguages(request.body as Record<string, unknown>) : 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<string, unknown>);
|
|
});
|
|
|
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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);
|
|
}
|