feat: stabilization + recipe edit/create UI

This commit is contained in:
clawd
2026-02-18 09:55:39 +00:00
commit ee452efa6a
75 changed files with 15160 additions and 0 deletions

43
backend/src/routes/bot.ts Normal file
View File

@@ -0,0 +1,43 @@
import { FastifyInstance } from 'fastify';
import * as recipeSvc from '../services/recipe.service.js';
import * as imageSvc from '../services/image.service.js';
export async function botRoutes(app: FastifyInstance) {
const BOT_TOKEN = process.env.BOT_API_TOKEN;
app.addHook('onRequest', async (request, reply) => {
if (!BOT_TOKEN) return reply.status(503).send({ error: 'Bot API not configured' });
const auth = request.headers.authorization;
if (!auth || auth !== `Bearer ${BOT_TOKEN}`) {
return reply.status(401).send({ error: 'Unauthorized' });
}
});
app.get('/api/bot/recipes', async (request) => {
const query = request.query as any;
return recipeSvc.listRecipes({
page: query.page ? Number(query.page) : undefined,
limit: query.limit ? Number(query.limit) : undefined,
});
});
app.post('/api/bot/recipes', async (request, reply) => {
const body = request.body as recipeSvc.CreateRecipeInput;
if (!body.title) return reply.status(400).send({ error: 'title required' });
const recipe = recipeSvc.createRecipe(body);
return reply.status(201).send(recipe);
});
app.post('/api/bot/recipes/:id/image-url', async (request, reply) => {
const { id } = request.params as { id: string };
const { url } = request.body as { url: string };
if (!url) return reply.status(400).send({ error: 'url required' });
try {
const result = await imageSvc.downloadAndSaveImage(id, url);
if (!result) return reply.status(404).send({ error: 'Recipe not found' });
return result;
} catch (e: any) {
return reply.status(400).send({ error: e.message });
}
});
}

View File

@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify';
import { listCategories, createCategory } from '../services/recipe.service.js';
export async function categoryRoutes(app: FastifyInstance) {
app.get('/api/categories', async () => {
return listCategories();
});
app.post('/api/categories', async (request, reply) => {
const { name } = request.body as { name: string };
if (!name) return reply.status(400).send({ error: 'name required' });
const cat = createCategory(name);
return reply.status(201).send(cat);
});
}

View File

@@ -0,0 +1,7 @@
import { FastifyInstance } from 'fastify';
export async function healthRoutes(app: FastifyInstance) {
app.get('/api/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
}

View File

@@ -0,0 +1,40 @@
import { FastifyInstance } from 'fastify';
import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import path from 'path';
import { fileURLToPath } from 'url';
import * as svc from '../services/image.service.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export async function imageRoutes(app: FastifyInstance) {
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });
await app.after();
await app.register(fastifyStatic, {
root: path.resolve(__dirname, '../../data/images'),
prefix: '/images/',
decorateReply: false,
});
await app.after();
app.post('/api/recipes/:id/image', async (request, reply) => {
const { id } = request.params as { id: string };
const file = await request.file();
if (!file) return reply.status(400).send({ error: 'No file uploaded' });
const buffer = await file.toBuffer();
const result = await svc.saveRecipeImage(id, buffer);
if (!result) return reply.status(404).send({ error: 'Recipe not found' });
return result;
});
app.post('/api/recipes/:id/steps/:stepNumber/image', async (request, reply) => {
const { id, stepNumber } = request.params as { id: string; stepNumber: string };
const file = await request.file();
if (!file) return reply.status(400).send({ error: 'No file uploaded' });
const buffer = await file.toBuffer();
const result = await svc.saveStepImage(id, Number(stepNumber), buffer);
if (!result) return reply.status(404).send({ error: 'Step not found' });
return result;
});
}

View File

@@ -0,0 +1,34 @@
import { FastifyInstance } from 'fastify';
import * as svc from '../services/note.service.js';
export async function noteRoutes(app: FastifyInstance) {
app.get('/api/recipes/:id/notes', async (request) => {
const { id } = request.params as { id: string };
return svc.listNotes(id);
});
app.post('/api/recipes/:id/notes', async (request, reply) => {
const { id } = request.params as { id: string };
const { content } = request.body as { content: string };
if (!content) return reply.status(400).send({ error: 'content required' });
const note = svc.createNote(id, content);
if (!note) return reply.status(404).send({ error: 'Recipe not found' });
return reply.status(201).send(note);
});
app.put('/api/notes/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const { content } = request.body as { content: string };
if (!content) return reply.status(400).send({ error: 'content required' });
const note = svc.updateNote(id, content);
if (!note) return reply.status(404).send({ error: 'Not found' });
return note;
});
app.delete('/api/notes/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = svc.deleteNote(id);
if (!ok) return reply.status(404).send({ error: 'Not found' });
return { ok: true };
});
}

View File

@@ -0,0 +1,59 @@
import { FastifyInstance } from 'fastify';
import * as svc from '../services/recipe.service.js';
export async function recipeRoutes(app: FastifyInstance) {
app.get('/api/recipes/search', async (request) => {
const { q } = request.query as { q?: string };
if (!q) return { data: [], total: 0 };
const results = svc.searchRecipes(q);
return { data: results, total: results.length };
});
app.get('/api/recipes', async (request) => {
const query = request.query as any;
return svc.listRecipes({
page: query.page ? Number(query.page) : undefined,
limit: query.limit ? Number(query.limit) : undefined,
category_id: query.category_id,
category_slug: query.category,
favorite: query.favorite !== undefined ? query.favorite === 'true' : undefined,
difficulty: query.difficulty,
maxTime: query.maxTime ? Number(query.maxTime) : undefined,
});
});
app.get('/api/recipes/:slug', async (request, reply) => {
const { slug } = request.params as { slug: string };
const recipe = svc.getRecipeBySlug(slug);
if (!recipe) return reply.status(404).send({ error: 'Not found' });
return recipe;
});
app.post('/api/recipes', async (request, reply) => {
const body = request.body as svc.CreateRecipeInput;
if (!body.title) return reply.status(400).send({ error: 'title required' });
const recipe = svc.createRecipe(body);
return reply.status(201).send(recipe);
});
app.put('/api/recipes/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const recipe = svc.updateRecipe(id, request.body as any);
if (!recipe) return reply.status(404).send({ error: 'Not found' });
return recipe;
});
app.delete('/api/recipes/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = svc.deleteRecipe(id);
if (!ok) return reply.status(404).send({ error: 'Not found' });
return { ok: true };
});
app.patch('/api/recipes/:id/favorite', async (request, reply) => {
const { id } = request.params as { id: string };
const result = svc.toggleFavorite(id);
if (!result) return reply.status(404).send({ error: 'Not found' });
return result;
});
}

View File

@@ -0,0 +1,41 @@
import { FastifyInstance } from 'fastify';
import * as svc from '../services/shopping.service.js';
export async function shoppingRoutes(app: FastifyInstance) {
app.get('/api/shopping', async () => {
return svc.listItems();
});
app.post('/api/shopping/from-recipe/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const items = svc.addFromRecipe(id);
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
return reply.status(201).send({ added: items.length });
});
app.post('/api/shopping', async (request, reply) => {
const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string };
if (!name) return reply.status(400).send({ error: 'name required' });
const item = svc.addItem(name, amount, unit);
return reply.status(201).send(item);
});
app.patch('/api/shopping/:id/check', async (request, reply) => {
const { id } = request.params as { id: string };
const item = svc.toggleCheck(id);
if (!item) return reply.status(404).send({ error: 'Not found' });
return item;
});
app.delete('/api/shopping/checked', async () => {
const count = svc.deleteChecked();
return { ok: true, deleted: count };
});
app.delete('/api/shopping/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = svc.deleteItem(id);
if (!ok) return reply.status(404).send({ error: 'Not found' });
return { ok: true };
});
}

View File

@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify';
import * as svc from '../services/tag.service.js';
export async function tagRoutes(app: FastifyInstance) {
app.get('/api/tags', async () => {
return svc.listTags();
});
app.get('/api/tags/:name/recipes', async (request, reply) => {
const { name } = request.params as { name: string };
const result = svc.getRecipesByTag(name);
if (!result) return reply.status(404).send({ error: 'Tag not found' });
return result;
});
}