first commit
This commit is contained in:
7
packages/api/src/config/database.ts
Normal file
7
packages/api/src/config/database.ts
Normal 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;
|
||||
10
packages/api/src/config/storage.ts
Normal file
10
packages/api/src/config/storage.ts
Normal 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
33
packages/api/src/index.ts
Normal 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}`);
|
||||
});
|
||||
256
packages/api/src/routes/recipes.routes.ts
Normal file
256
packages/api/src/routes/recipes.routes.ts
Normal 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;
|
||||
166
packages/api/src/services/scraper.service.ts
Normal file
166
packages/api/src/services/scraper.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
66
packages/api/src/services/storage.service.ts
Normal file
66
packages/api/src/services/storage.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user