200 lines
5.8 KiB
TypeScript
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,
|
|
};
|
|
});
|
|
}
|
|
}
|