first commit

This commit is contained in:
2025-10-21 22:04:03 -06:00
commit 4e71ef9c66
36 changed files with 2271 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
export default prisma;

View File

@@ -0,0 +1,10 @@
import { StorageConfig } from '@basil/shared';
export const storageConfig: StorageConfig = {
type: (process.env.STORAGE_TYPE as 'local' | 's3') || 'local',
localPath: process.env.LOCAL_STORAGE_PATH || './uploads',
s3Bucket: process.env.S3_BUCKET,
s3Region: process.env.S3_REGION,
s3AccessKey: process.env.S3_ACCESS_KEY_ID,
s3SecretKey: process.env.S3_SECRET_ACCESS_KEY,
};

33
packages/api/src/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import path from 'path';
import recipesRoutes from './routes/recipes.routes';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Serve uploaded files
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// Routes
app.use('/api/recipes', recipesRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Start server
app.listen(PORT, () => {
console.log(`🌿 Basil API server running on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,256 @@
import { Router } from 'express';
import multer from 'multer';
import prisma from '../config/database';
import { StorageService } from '../services/storage.service';
import { ScraperService } from '../services/scraper.service';
import { ApiResponse, RecipeImportRequest } from '@basil/shared';
const router = Router();
const upload = multer({ storage: multer.memoryStorage() });
const storageService = StorageService.getInstance();
const scraperService = new ScraperService();
// Get all recipes
router.get('/', async (req, res) => {
try {
const { page = '1', limit = '20', search, cuisine, category } = req.query;
const pageNum = parseInt(page as string);
const limitNum = parseInt(limit as string);
const skip = (pageNum - 1) * limitNum;
const where: any = {};
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ description: { contains: search as string, mode: 'insensitive' } },
];
}
if (cuisine) where.cuisine = cuisine;
if (category) where.category = category;
const [recipes, total] = await Promise.all([
prisma.recipe.findMany({
where,
skip,
take: limitNum,
include: {
ingredients: { orderBy: { order: 'asc' } },
instructions: { orderBy: { step: 'asc' } },
images: { orderBy: { order: 'asc' } },
tags: { include: { tag: true } },
},
orderBy: { createdAt: 'desc' },
}),
prisma.recipe.count({ where }),
]);
res.json({
data: recipes,
total,
page: pageNum,
pageSize: limitNum,
});
} catch (error) {
console.error('Error fetching recipes:', error);
res.status(500).json({ error: 'Failed to fetch recipes' });
}
});
// Get single recipe
router.get('/:id', async (req, res) => {
try {
const recipe = await prisma.recipe.findUnique({
where: { id: req.params.id },
include: {
ingredients: { orderBy: { order: 'asc' } },
instructions: { orderBy: { step: 'asc' } },
images: { orderBy: { order: 'asc' } },
tags: { include: { tag: true } },
},
});
if (!recipe) {
return res.status(404).json({ error: 'Recipe not found' });
}
res.json({ data: recipe });
} catch (error) {
console.error('Error fetching recipe:', error);
res.status(500).json({ error: 'Failed to fetch recipe' });
}
});
// Create recipe
router.post('/', async (req, res) => {
try {
const { title, description, ingredients, instructions, tags, ...recipeData } = req.body;
const recipe = await prisma.recipe.create({
data: {
title,
description,
...recipeData,
ingredients: {
create: ingredients?.map((ing: any, index: number) => ({
...ing,
order: ing.order ?? index,
})),
},
instructions: {
create: instructions?.map((inst: any) => inst),
},
tags: tags
? {
create: tags.map((tagName: string) => ({
tag: {
connectOrCreate: {
where: { name: tagName },
create: { name: tagName },
},
},
})),
}
: undefined,
},
include: {
ingredients: true,
instructions: true,
images: true,
tags: { include: { tag: true } },
},
});
res.status(201).json({ data: recipe });
} catch (error) {
console.error('Error creating recipe:', error);
res.status(500).json({ error: 'Failed to create recipe' });
}
});
// Update recipe
router.put('/:id', async (req, res) => {
try {
const { ingredients, instructions, tags, ...recipeData } = req.body;
// Delete existing relations
await prisma.ingredient.deleteMany({ where: { recipeId: req.params.id } });
await prisma.instruction.deleteMany({ where: { recipeId: req.params.id } });
await prisma.recipeTag.deleteMany({ where: { recipeId: req.params.id } });
const recipe = await prisma.recipe.update({
where: { id: req.params.id },
data: {
...recipeData,
ingredients: ingredients
? {
create: ingredients.map((ing: any, index: number) => ({
...ing,
order: ing.order ?? index,
})),
}
: undefined,
instructions: instructions ? { create: instructions } : undefined,
tags: tags
? {
create: tags.map((tagName: string) => ({
tag: {
connectOrCreate: {
where: { name: tagName },
create: { name: tagName },
},
},
})),
}
: undefined,
},
include: {
ingredients: true,
instructions: true,
images: true,
tags: { include: { tag: true } },
},
});
res.json({ data: recipe });
} catch (error) {
console.error('Error updating recipe:', error);
res.status(500).json({ error: 'Failed to update recipe' });
}
});
// Delete recipe
router.delete('/:id', async (req, res) => {
try {
// Get recipe to delete associated images
const recipe = await prisma.recipe.findUnique({
where: { id: req.params.id },
include: { images: true },
});
if (recipe) {
// Delete images from storage
if (recipe.imageUrl) {
await storageService.deleteFile(recipe.imageUrl);
}
for (const image of recipe.images) {
await storageService.deleteFile(image.url);
}
}
await prisma.recipe.delete({ where: { id: req.params.id } });
res.json({ message: 'Recipe deleted successfully' });
} catch (error) {
console.error('Error deleting recipe:', error);
res.status(500).json({ error: 'Failed to delete recipe' });
}
});
// Upload image
router.post('/:id/images', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No image provided' });
}
const imageUrl = await storageService.saveFile(req.file, 'recipes');
// Add to recipe images
const image = await prisma.recipeImage.create({
data: {
recipeId: req.params.id,
url: imageUrl,
order: 0,
},
});
res.json({ data: image });
} catch (error) {
console.error('Error uploading image:', error);
res.status(500).json({ error: 'Failed to upload image' });
}
});
// Import recipe from URL
router.post('/import', async (req, res) => {
try {
const { url }: RecipeImportRequest = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
const result = await scraperService.scrapeRecipe(url);
if (!result.success) {
return res.status(400).json({ error: result.error });
}
res.json(result);
} catch (error) {
console.error('Error importing recipe:', error);
res.status(500).json({ error: 'Failed to import recipe' });
}
});
export default router;

View File

@@ -0,0 +1,166 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import { Recipe, RecipeImportResponse } from '@basil/shared';
export class ScraperService {
async scrapeRecipe(url: string): Promise<RecipeImportResponse> {
try {
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; BasilBot/1.0)',
},
timeout: 10000,
});
const html = response.data;
const $ = cheerio.load(html);
// Try to find JSON-LD schema.org Recipe markup
const recipeData = this.extractSchemaOrgRecipe($);
if (recipeData) {
return {
success: true,
recipe: {
...recipeData,
sourceUrl: url,
},
};
}
// Fallback to manual parsing if no schema found
const fallbackData = this.extractRecipeFallback($);
return {
success: true,
recipe: {
...fallbackData,
sourceUrl: url,
},
};
} catch (error) {
console.error('Error scraping recipe:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to scrape recipe',
recipe: {},
};
}
}
private extractSchemaOrgRecipe($: cheerio.CheerioAPI): Partial<Recipe> | null {
const scripts = $('script[type="application/ld+json"]');
for (let i = 0; i < scripts.length; i++) {
try {
const content = $(scripts[i]).html();
if (!content) continue;
const json = JSON.parse(content);
const recipeData = Array.isArray(json)
? json.find((item) => item['@type'] === 'Recipe')
: json['@type'] === 'Recipe'
? json
: null;
if (recipeData) {
return {
title: recipeData.name,
description: recipeData.description,
prepTime: this.parseDuration(recipeData.prepTime),
cookTime: this.parseDuration(recipeData.cookTime),
totalTime: this.parseDuration(recipeData.totalTime),
servings: parseInt(recipeData.recipeYield) || undefined,
imageUrl: this.extractImageUrl(recipeData.image),
author: recipeData.author?.name || recipeData.author,
cuisine: recipeData.recipeCuisine,
category: recipeData.recipeCategory,
rating: recipeData.aggregateRating?.ratingValue,
ingredients: this.parseIngredients(recipeData.recipeIngredient),
instructions: this.parseInstructions(recipeData.recipeInstructions),
};
}
} catch (error) {
continue;
}
}
return null;
}
private extractRecipeFallback($: cheerio.CheerioAPI): Partial<Recipe> {
// Basic fallback extraction
const title = $('h1').first().text().trim() || $('title').text().trim();
const description = $('meta[name="description"]').attr('content');
const imageUrl = $('meta[property="og:image"]').attr('content');
return {
title,
description,
imageUrl,
ingredients: [],
instructions: [],
};
}
private parseDuration(duration?: string): number | undefined {
if (!duration) return undefined;
// Parse ISO 8601 duration format (PT30M, PT1H30M, etc.)
const matches = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?/);
if (matches) {
const hours = parseInt(matches[1]) || 0;
const minutes = parseInt(matches[2]) || 0;
return hours * 60 + minutes;
}
return undefined;
}
private extractImageUrl(image: any): string | undefined {
if (!image) return undefined;
if (typeof image === 'string') return image;
if (Array.isArray(image)) return image[0];
if (image.url) return image.url;
return undefined;
}
private parseIngredients(ingredients?: string[]): any[] {
if (!ingredients || !Array.isArray(ingredients)) return [];
return ingredients.map((ingredient, index) => ({
name: ingredient,
order: index,
}));
}
private parseInstructions(instructions?: any): any[] {
if (!instructions) return [];
if (typeof instructions === 'string') {
return [{ step: 1, text: instructions }];
}
if (Array.isArray(instructions)) {
return instructions.map((instruction, index) => {
if (typeof instruction === 'string') {
return { step: index + 1, text: instruction };
}
if (instruction.text) {
return { step: index + 1, text: instruction.text };
}
return { step: index + 1, text: JSON.stringify(instruction) };
});
}
return [];
}
async downloadImage(imageUrl: string): Promise<Buffer> {
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer',
timeout: 10000,
});
return Buffer.from(response.data);
}
}

View File

@@ -0,0 +1,66 @@
import fs from 'fs/promises';
import path from 'path';
import { storageConfig } from '../config/storage';
export class StorageService {
private static instance: StorageService;
private constructor() {
this.initialize();
}
static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
private async initialize() {
if (storageConfig.type === 'local' && storageConfig.localPath) {
await fs.mkdir(storageConfig.localPath, { recursive: true });
}
}
async saveFile(file: Express.Multer.File, folder: string = 'images'): Promise<string> {
if (storageConfig.type === 'local') {
return this.saveFileLocally(file, folder);
} else if (storageConfig.type === 's3') {
return this.saveFileToS3(file, folder);
}
throw new Error('Invalid storage type');
}
private async saveFileLocally(file: Express.Multer.File, folder: string): Promise<string> {
const basePath = storageConfig.localPath || './uploads';
const folderPath = path.join(basePath, folder);
await fs.mkdir(folderPath, { recursive: true });
const filename = `${Date.now()}-${file.originalname}`;
const filePath = path.join(folderPath, filename);
await fs.writeFile(filePath, file.buffer);
return `/uploads/${folder}/${filename}`;
}
private async saveFileToS3(_file: Express.Multer.File, _folder: string): Promise<string> {
// TODO: Implement S3 upload using AWS SDK
throw new Error('S3 storage not yet implemented');
}
async deleteFile(fileUrl: string): Promise<void> {
if (storageConfig.type === 'local') {
const basePath = storageConfig.localPath || './uploads';
const filePath = path.join(basePath, fileUrl.replace('/uploads/', ''));
try {
await fs.unlink(filePath);
} catch (error) {
console.error('Error deleting file:', error);
}
} else if (storageConfig.type === 's3') {
// TODO: Implement S3 delete
throw new Error('S3 storage not yet implemented');
}
}
}