recipe-manager/src/backend/repositories/RecipeRepository.ts

200 lines
5.8 KiB
TypeScript

import type { Database, SqlValue } from 'sql.js';
import type { Recipe, CreateRecipeInput, UpdateRecipeInput, RecipeFilters } from '../types/recipe.js';
/**
* RecipeRepository handles all database operations for recipes.
*/
export class RecipeRepository {
constructor(private db: Database) {}
/**
* Find all recipes with optional filtering and pagination
*/
findAll(filters: RecipeFilters = {}): Recipe[] {
const { search, offset = 0, limit = 50 } = filters;
let sql = 'SELECT * FROM recipes';
const params: SqlValue[] = [];
if (search) {
sql += ' WHERE title LIKE ? OR description LIKE ? OR ingredients LIKE ?';
const searchPattern = `%${search}%`;
params.push(searchPattern, searchPattern, searchPattern);
}
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const result = this.db.exec(sql, params);
if (!result.length) return [];
return this.rowsToRecipes(result[0]);
}
/**
* Find a recipe by ID
*/
findById(id: number): Recipe | null {
const result = this.db.exec('SELECT * FROM recipes WHERE id = ?', [id]);
if (!result.length || !result[0].values.length) return null;
const recipes = this.rowsToRecipes(result[0]);
return recipes[0] || null;
}
/**
* Create a new recipe
*/
create(input: CreateRecipeInput): Recipe {
const now = Math.floor(Date.now() / 1000);
const sql = `
INSERT INTO recipes (
title, description, ingredients, instructions,
source_url, notes, servings, prep_time_minutes,
cook_time_minutes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
this.db.run(sql, [
input.title,
input.description || null,
JSON.stringify(input.ingredients),
JSON.stringify(input.instructions),
input.source_url || null,
input.notes || null,
input.servings || null,
input.prep_time_minutes || null,
input.cook_time_minutes || null,
now,
now,
]);
// Get the last inserted ID
const result = this.db.exec('SELECT last_insert_rowid() as id');
const id = result[0].values[0][0] as number;
return this.findById(id)!;
}
/**
* Update an existing recipe
*/
update(id: number, input: UpdateRecipeInput): Recipe | null {
const existing = this.findById(id);
if (!existing) return null;
const now = Math.floor(Date.now() / 1000);
const fields: string[] = [];
const params: SqlValue[] = [];
// Build dynamic UPDATE query based on provided fields
if (input.title !== undefined) {
fields.push('title = ?');
params.push(input.title);
}
if (input.description !== undefined) {
fields.push('description = ?');
params.push(input.description);
}
if (input.ingredients !== undefined) {
fields.push('ingredients = ?');
params.push(JSON.stringify(input.ingredients));
}
if (input.instructions !== undefined) {
fields.push('instructions = ?');
params.push(JSON.stringify(input.instructions));
}
if (input.source_url !== undefined) {
fields.push('source_url = ?');
params.push(input.source_url);
}
if (input.notes !== undefined) {
fields.push('notes = ?');
params.push(input.notes);
}
if (input.servings !== undefined) {
fields.push('servings = ?');
params.push(input.servings);
}
if (input.prep_time_minutes !== undefined) {
fields.push('prep_time_minutes = ?');
params.push(input.prep_time_minutes);
}
if (input.cook_time_minutes !== undefined) {
fields.push('cook_time_minutes = ?');
params.push(input.cook_time_minutes);
}
// Always update updated_at
fields.push('updated_at = ?');
params.push(now);
// Add ID to params for WHERE clause
params.push(id);
const sql = `UPDATE recipes SET ${fields.join(', ')} WHERE id = ?`;
this.db.run(sql, params);
return this.findById(id);
}
/**
* Delete a recipe
*/
delete(id: number): boolean {
const existing = this.findById(id);
if (!existing) return false;
this.db.run('DELETE FROM recipes WHERE id = ?', [id]);
return true;
}
/**
* Count total recipes (for pagination)
*/
count(filters: RecipeFilters = {}): number {
const { search } = filters;
let sql = 'SELECT COUNT(*) as count FROM recipes';
const params: SqlValue[] = [];
if (search) {
sql += ' WHERE title LIKE ? OR description LIKE ? OR ingredients LIKE ?';
const searchPattern = `%${search}%`;
params.push(searchPattern, searchPattern, searchPattern);
}
const result = this.db.exec(sql, params);
return result[0].values[0][0] as number;
}
/**
* Convert sql.js result rows to Recipe objects
*/
private rowsToRecipes(result: { columns: string[]; values: SqlValue[][] }): Recipe[] {
return result.values.map((row) => {
const recipe: Record<string, SqlValue> = {};
result.columns.forEach((col, idx) => {
recipe[col] = row[idx];
});
return {
id: recipe.id as number,
title: recipe.title as string,
description: recipe.description as string | null,
ingredients: JSON.parse(recipe.ingredients as string) as string[],
instructions: JSON.parse(recipe.instructions as string) as string[],
source_url: recipe.source_url as string | null,
notes: recipe.notes as string | null,
servings: recipe.servings as number | null,
prep_time_minutes: recipe.prep_time_minutes as number | null,
cook_time_minutes: recipe.cook_time_minutes as number | null,
created_at: recipe.created_at as number,
updated_at: recipe.updated_at as number,
last_cooked_at: recipe.last_cooked_at as number | null,
};
});
}
}