286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import { getDb } from '../db/connection.js';
|
|
import { ulid } from 'ulid';
|
|
|
|
function syncTags(db: any, recipeId: string, tags: string[]) {
|
|
db.prepare('DELETE FROM recipe_tags WHERE recipe_id = ?').run(recipeId);
|
|
for (const tagName of tags) {
|
|
const trimmed = tagName.trim();
|
|
if (!trimmed) continue;
|
|
const slug = trimmed.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
|
|
let tag = db.prepare('SELECT id FROM tags WHERE slug = ?').get(slug) as any;
|
|
if (!tag) {
|
|
const tagId = ulid();
|
|
db.prepare('INSERT INTO tags (id, name, slug) VALUES (?, ?, ?)').run(tagId, trimmed, slug);
|
|
tag = { id: tagId };
|
|
}
|
|
db.prepare('INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)').run(recipeId, tag.id);
|
|
}
|
|
}
|
|
|
|
function slugify(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
|
|
function ensureUniqueSlug(baseSlug: string, excludeId?: string): string {
|
|
const db = getDb();
|
|
let slug = baseSlug;
|
|
let i = 1;
|
|
while (true) {
|
|
const existing = excludeId
|
|
? db.prepare('SELECT id FROM recipes WHERE slug = ? AND id != ?').get(slug, excludeId)
|
|
: db.prepare('SELECT id FROM recipes WHERE slug = ?').get(slug);
|
|
if (!existing) return slug;
|
|
slug = `${baseSlug}-${i++}`;
|
|
}
|
|
}
|
|
|
|
export interface CreateRecipeInput {
|
|
title: string;
|
|
description?: string;
|
|
category_id?: string;
|
|
difficulty?: 'easy' | 'medium' | 'hard';
|
|
prep_time?: number;
|
|
cook_time?: number;
|
|
servings?: number;
|
|
image_url?: string;
|
|
source_url?: string;
|
|
ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[];
|
|
steps?: { step_number: number; instruction: string; duration_minutes?: number }[];
|
|
tags?: string[];
|
|
}
|
|
|
|
function mapTimeFields(row: any) {
|
|
if (!row) return row;
|
|
row.prep_time_min = row.prep_time ?? null;
|
|
row.cook_time_min = row.cook_time ?? null;
|
|
row.total_time_min = row.total_time ?? null;
|
|
return row;
|
|
}
|
|
|
|
export function listRecipes(opts: {
|
|
page?: number; limit?: number; category_id?: string; category_slug?: string;
|
|
favorite?: boolean; difficulty?: string; maxTime?: number; userId?: string;
|
|
}) {
|
|
const db = getDb();
|
|
const page = opts.page || 1;
|
|
const limit = opts.limit || 20;
|
|
const offset = (page - 1) * limit;
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
|
|
if (opts.category_id) { conditions.push('r.category_id = ?'); params.push(opts.category_id); }
|
|
if (opts.category_slug) { conditions.push('c.slug = ?'); params.push(opts.category_slug); }
|
|
if (opts.favorite !== undefined && opts.userId) {
|
|
conditions.push('uf.user_id IS ' + (opts.favorite ? 'NOT NULL' : 'NULL'));
|
|
}
|
|
if (opts.difficulty) { conditions.push('r.difficulty = ?'); params.push(opts.difficulty); }
|
|
if (opts.maxTime) { conditions.push('r.total_time <= ?'); params.push(opts.maxTime); }
|
|
|
|
let joins = 'LEFT JOIN categories c ON r.category_id = c.id';
|
|
if (opts.userId) {
|
|
joins += ' LEFT JOIN user_favorites uf ON r.id = uf.recipe_id AND uf.user_id = ?';
|
|
params.unshift(opts.userId);
|
|
}
|
|
|
|
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
|
|
|
|
// Adjust parameter positions based on whether userId is included
|
|
const countParams = opts.userId ? [opts.userId, ...params.slice(1)] : params;
|
|
const dataParams = opts.userId ? [opts.userId, ...params.slice(1), limit, offset] : [...params, limit, offset];
|
|
|
|
const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r ${joins} ${where}`).get(...countParams) as any;
|
|
const rows = db.prepare(
|
|
`SELECT r.*, c.name as category_name, c.slug as category_slug,
|
|
${opts.userId ? '(uf.user_id IS NOT NULL) as is_favorite' : 'r.is_favorite'}
|
|
FROM recipes r ${joins} ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?`
|
|
).all(...dataParams);
|
|
|
|
const data = rows.map(mapTimeFields);
|
|
return { data, total: countRow.total, page, limit, totalPages: Math.ceil(countRow.total / limit) };
|
|
}
|
|
|
|
export function getRecipeBySlug(slug: string) {
|
|
const db = getDb();
|
|
const recipe = db.prepare(
|
|
'SELECT r.*, c.name as category_name FROM recipes r LEFT JOIN categories c ON r.category_id = c.id WHERE r.slug = ?'
|
|
).get(slug) as any;
|
|
if (!recipe) return null;
|
|
|
|
recipe.ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ? ORDER BY sort_order').all(recipe.id);
|
|
recipe.steps = db.prepare('SELECT * FROM steps WHERE recipe_id = ? ORDER BY step_number').all(recipe.id);
|
|
recipe.notes = db.prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipe.id);
|
|
const tagRows = db.prepare(
|
|
'SELECT t.name FROM tags t JOIN recipe_tags rt ON t.id = rt.tag_id WHERE rt.recipe_id = ?'
|
|
).all(recipe.id) as { name: string }[];
|
|
recipe.tags = tagRows.map(t => t.name);
|
|
mapTimeFields(recipe);
|
|
return recipe;
|
|
}
|
|
|
|
export function createRecipe(input: CreateRecipeInput) {
|
|
const db = getDb();
|
|
const id = ulid();
|
|
const slug = ensureUniqueSlug(slugify(input.title));
|
|
const totalTime = (input.prep_time || 0) + (input.cook_time || 0) || null;
|
|
|
|
const insertRecipe = db.prepare(`
|
|
INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
const insertIngredient = db.prepare(`
|
|
INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
const insertStep = db.prepare(`
|
|
INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
const transaction = db.transaction(() => {
|
|
insertRecipe.run(id, input.title, slug, input.description || null, input.category_id || null,
|
|
input.difficulty || 'medium', input.prep_time || null, input.cook_time || null, totalTime,
|
|
input.servings || 4, input.image_url || null, input.source_url || null);
|
|
|
|
if (input.ingredients) {
|
|
for (let i = 0; i < input.ingredients.length; i++) {
|
|
const ing = input.ingredients[i];
|
|
insertIngredient.run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i);
|
|
}
|
|
}
|
|
|
|
if (input.steps) {
|
|
for (const step of input.steps) {
|
|
insertStep.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null);
|
|
}
|
|
}
|
|
|
|
if (input.tags && input.tags.length > 0) {
|
|
syncTags(db, id, input.tags);
|
|
}
|
|
});
|
|
|
|
transaction();
|
|
return getRecipeBySlug(slug);
|
|
}
|
|
|
|
export function updateRecipe(id: string, input: Partial<CreateRecipeInput>) {
|
|
const db = getDb();
|
|
const existing = db.prepare('SELECT * FROM recipes WHERE id = ?').get(id) as any;
|
|
if (!existing) return null;
|
|
|
|
const slug = input.title ? ensureUniqueSlug(slugify(input.title), id) : existing.slug;
|
|
const totalTime = ((input.prep_time ?? existing.prep_time) || 0) + ((input.cook_time ?? existing.cook_time) || 0) || null;
|
|
|
|
db.prepare(`
|
|
UPDATE recipes SET title=?, slug=?, description=?, category_id=?, difficulty=?, prep_time=?, cook_time=?, total_time=?, servings=?, image_url=?, source_url=?, updated_at=datetime('now')
|
|
WHERE id=?
|
|
`).run(
|
|
input.title ?? existing.title, slug, input.description ?? existing.description,
|
|
input.category_id ?? existing.category_id, input.difficulty ?? existing.difficulty,
|
|
input.prep_time ?? existing.prep_time, input.cook_time ?? existing.cook_time, totalTime,
|
|
input.servings ?? existing.servings, input.image_url ?? existing.image_url,
|
|
input.source_url ?? existing.source_url, id
|
|
);
|
|
|
|
// Replace ingredients if provided
|
|
if (input.ingredients) {
|
|
db.prepare('DELETE FROM ingredients WHERE recipe_id = ?').run(id);
|
|
for (let i = 0; i < input.ingredients.length; i++) {
|
|
const ing = input.ingredients[i];
|
|
db.prepare('INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
.run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i);
|
|
}
|
|
}
|
|
|
|
// Replace steps if provided
|
|
if (input.steps) {
|
|
db.prepare('DELETE FROM steps WHERE recipe_id = ?').run(id);
|
|
for (const step of input.steps) {
|
|
db.prepare('INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) VALUES (?, ?, ?, ?, ?)')
|
|
.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null);
|
|
}
|
|
}
|
|
|
|
// Sync tags if provided
|
|
if (input.tags) {
|
|
syncTags(db, id, input.tags);
|
|
}
|
|
|
|
return getRecipeBySlug(slug);
|
|
}
|
|
|
|
export function deleteRecipe(id: string): boolean {
|
|
const result = getDb().prepare('DELETE FROM recipes WHERE id = ?').run(id);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
export function toggleFavorite(id: string, userId?: string) {
|
|
const db = getDb();
|
|
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any;
|
|
if (!recipe) return null;
|
|
|
|
// If no user authentication, fallback to old is_favorite column
|
|
if (!userId) {
|
|
const recipeWithFavorite = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any;
|
|
const newVal = recipeWithFavorite.is_favorite ? 0 : 1;
|
|
db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id);
|
|
return { id, is_favorite: newVal };
|
|
}
|
|
|
|
// Check if recipe is already favorited by user
|
|
const existing = db.prepare(
|
|
'SELECT id FROM user_favorites WHERE user_id = ? AND recipe_id = ?'
|
|
).get(userId, id) as any;
|
|
|
|
if (existing) {
|
|
// Remove from favorites
|
|
db.prepare('DELETE FROM user_favorites WHERE user_id = ? AND recipe_id = ?').run(userId, id);
|
|
return { id, is_favorite: false };
|
|
} else {
|
|
// Add to favorites
|
|
const favoriteId = ulid();
|
|
db.prepare('INSERT INTO user_favorites (id, user_id, recipe_id) VALUES (?, ?, ?)').run(favoriteId, userId, id);
|
|
return { id, is_favorite: true };
|
|
}
|
|
}
|
|
|
|
export function getRandomRecipe() {
|
|
const db = getDb();
|
|
const recipe = db.prepare('SELECT slug FROM recipes ORDER BY RANDOM() LIMIT 1').get() as any;
|
|
if (!recipe) return null;
|
|
return getRecipeBySlug(recipe.slug);
|
|
}
|
|
|
|
export function searchRecipes(query: string) {
|
|
const db = getDb();
|
|
// Add * for prefix matching
|
|
const ftsQuery = query.trim().split(/\s+/).map(t => `"${t}"*`).join(' ');
|
|
return db.prepare(`
|
|
SELECT r.*, c.name as category_name
|
|
FROM recipes_fts fts
|
|
JOIN recipes r ON r.rowid = fts.rowid
|
|
LEFT JOIN categories c ON r.category_id = c.id
|
|
WHERE recipes_fts MATCH ?
|
|
ORDER BY rank
|
|
`).all(ftsQuery).map(mapTimeFields);
|
|
}
|
|
|
|
export function listCategories() {
|
|
return getDb().prepare('SELECT * FROM categories ORDER BY sort_order').all();
|
|
}
|
|
|
|
export function createCategory(name: string) {
|
|
const db = getDb();
|
|
const id = ulid();
|
|
const slug = name.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
|
|
const maxOrder = (db.prepare('SELECT MAX(sort_order) as m FROM categories').get() as any).m || 0;
|
|
db.prepare('INSERT INTO categories (id, name, slug, sort_order) VALUES (?, ?, ?, ?)').run(id, name, slug, maxOrder + 1);
|
|
return { id, name, slug, sort_order: maxOrder + 1 };
|
|
}
|