946 lines
28 KiB
Plaintext
946 lines
28 KiB
Plaintext
Här är en **komplett plan i markdown-format** som du kan spara som en fil (t.ex. `premium_recipe_feature_plan.md`) och ladda upp i **VS Code-chatten** för att implementera **AI som en premium-funktion** för receptgenerering. Filen är strukturerad för att vara **lätt att följa** och innehåller alla nödvändiga steg, kodsnuttar och förklaringar.
|
||
|
||
---
|
||
|
||
```markdown
|
||
# 📌 Plan: Implementera AI som Premium-Funktion för Receptgenerering
|
||
|
||
---
|
||
## **🎯 Översikt**
|
||
Denna plan beskriver hur du implementerar **AI som en premium-funktion** i din app, där:
|
||
- **Gratis-användare** får **grundläggande recept** (genererade från mallar).
|
||
- **Premium-användare** får **AI-genererade recept** (mer kreativa, personliga och intelligenta).
|
||
- Systemet är **modulärt** och kan enkelt utökas.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📋 Förutsättningar**
|
||
1. **Node.js/TypeScript** (backend).
|
||
2. **Prisma** (databas).
|
||
3. **pdf-parse** och **Tesseract.js** (PDF-extrahering).
|
||
4. **Mistral API-nyckel** (för AI-funktioner).
|
||
5. **Frontend-ramverk** (t.ex. React, Next.js, eller liknande).
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **🛠️ Databasschema (Prisma)**
|
||
Lägg till fält för att spåra **premium-status** i din `user`-tabell:
|
||
|
||
```prisma
|
||
model User {
|
||
id Int @id @default(autoincrement())
|
||
// ... andra fält ...
|
||
is_premium Boolean @default(false)
|
||
premium_expiry_date DateTime?
|
||
}
|
||
```
|
||
|
||
**SQL för att lägga till fält om tabellen redan finns:**
|
||
```sql
|
||
ALTER TABLE "User" ADD COLUMN "is_premium" BOOLEAN DEFAULT false;
|
||
ALTER TABLE "User" ADD COLUMN "premium_expiry_date" TIMESTAMP;
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📁 Filstruktur**
|
||
```
|
||
src/
|
||
├── services/
|
||
│ ├── pdfTextExtractor.ts # Extraherar text från PDF (pdf-parse + OCR)
|
||
│ ├── willysParser.ts # Parsar text till strukturerad data (regex)
|
||
│ ├── inventoryMatcher.ts # Matchar produkter med inventory (fuzzy matching)
|
||
│ └── recipeGenerator.ts # Genererar recept (AI för premium, mallar för gratis)
|
||
├── api/
|
||
│ ├── pdfImport.ts # API-endpoint för PDF-import
|
||
│ └── premium.ts # API-endpoint för premium-uppgradering
|
||
└── types/
|
||
└── recipeTypes.ts # Typer för recept och produkter
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📄 1. `pdfTextExtractor.ts` – Extrahera text från PDF**
|
||
Använder `pdf-parse` som primär metod och `Tesseract.js` som fallback för OCR.
|
||
|
||
```typescript
|
||
import * as fs from 'fs';
|
||
import * as pdf from 'pdf-parse';
|
||
import Tesseract from 'tesseract.js';
|
||
|
||
/**
|
||
* Extraherar text från en PDF-fil, med fallback till OCR om nödvändigt.
|
||
* @param pdfPath Sökväg till PDF-filen.
|
||
* @returns Extraherad text.
|
||
*/
|
||
export async function extractTextFromPDF(pdfPath: string): Promise<string> {
|
||
try {
|
||
const dataBuffer = fs.readFileSync(pdfPath);
|
||
const data = await pdf(dataBuffer);
|
||
if (data.text.trim()) return data.text;
|
||
} catch (error) {
|
||
console.warn('pdf-parse misslyckades, försöker med OCR...', error);
|
||
}
|
||
|
||
try {
|
||
const { data: { text } } = await Tesseract.recognize(pdfPath, 'swe', {
|
||
logger: (m) => console.log(m),
|
||
});
|
||
return text;
|
||
} catch (error) {
|
||
console.error('OCR misslyckades:', error);
|
||
throw new Error('Kunde inte extrahera text från PDF:en.');
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📄 2. `willysParser.ts` – Parsa text till strukturerad data**
|
||
Använder **regex** för att extrahera produktnamn, priser, jämförpriser, etc. från Willys veckoblad.
|
||
|
||
```typescript
|
||
/**
|
||
* Parsar text från Willys veckoblad till strukturerad data.
|
||
* @param text Den extraherade texten.
|
||
* @returns Strukturerad data (JSON-array).
|
||
*/
|
||
export function parseWillysText(text: string): any[] {
|
||
const lines = text.split('\n');
|
||
const products: any[] = [];
|
||
let currentProduct: any = {};
|
||
let currentCategory: string | null = null;
|
||
|
||
// Regex-mönster
|
||
const productLineRegex = /^(.*?)\s*•\s*(.*?)\s*•\s*(.*?)\s*•?\s*Jämförpris\s*([\d:]+)\s*kr\/([a-z]+)/i;
|
||
const simpleProductLineRegex = /^(.*?)\s*•\s*(.*?)\s*•\s*(.*?)$/i;
|
||
const priceLineRegex = /^([\d:]+)\s*(Per\s*(förp|kg|st|l|))?/i;
|
||
const offerLineRegex = /^(Max \d+ (köp|förp)\/hushåll|Lägsta 30-dgrspris [\d:]+ kr)/i;
|
||
const categoryLineRegex = /^(Fisk|Kött|Mejeri|Grönsaker|Frukt|Dryck|Bröd|Pasta|Ris)/i;
|
||
|
||
for (const line of lines) {
|
||
const trimmedLine = line.trim();
|
||
if (!trimmedLine) continue;
|
||
|
||
// Matcha kategori
|
||
const categoryMatch = trimmedLine.match(categoryLineRegex);
|
||
if (categoryMatch) {
|
||
currentCategory = categoryMatch[1];
|
||
continue;
|
||
}
|
||
|
||
// Matcha produktrad (med jämförpris)
|
||
const productMatch = trimmedLine.match(productLineRegex);
|
||
if (productMatch) {
|
||
if (Object.keys(currentProduct).length > 0) {
|
||
currentProduct.category = currentCategory;
|
||
products.push(currentProduct);
|
||
currentProduct = {};
|
||
}
|
||
currentProduct = {
|
||
name: productMatch[1].trim(),
|
||
weight: productMatch[2].trim(),
|
||
origin: productMatch[3].trim(),
|
||
comparisonPrice: `${productMatch[4].replace(':', '.')} kr/${productMatch[5]}`,
|
||
category: currentCategory,
|
||
};
|
||
continue;
|
||
}
|
||
|
||
// Matcha enkel produktrad
|
||
const simpleProductMatch = trimmedLine.match(simpleProductLineRegex);
|
||
if (simpleProductMatch) {
|
||
if (Object.keys(currentProduct).length > 0) {
|
||
currentProduct.category = currentCategory;
|
||
products.push(currentProduct);
|
||
currentProduct = {};
|
||
}
|
||
currentProduct = {
|
||
name: simpleProductMatch[1].trim(),
|
||
weight: simpleProductMatch[2].trim(),
|
||
origin: simpleProductMatch[3].trim(),
|
||
category: currentCategory,
|
||
};
|
||
continue;
|
||
}
|
||
|
||
// Matcha pris
|
||
const priceMatch = trimmedLine.match(priceLineRegex);
|
||
if (priceMatch) {
|
||
currentProduct.price = `${priceMatch[1].replace(':', '.')} kr/${priceMatch[2]?.trim() || 'förp'}`;
|
||
continue;
|
||
}
|
||
|
||
// Matcha erbjudande
|
||
const offerMatch = trimmedLine.match(offerLineRegex);
|
||
if (offerMatch) {
|
||
currentProduct.offer = offerMatch[1];
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (Object.keys(currentProduct).length > 0) {
|
||
currentProduct.category = currentCategory;
|
||
products.push(currentProduct);
|
||
}
|
||
|
||
return products;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📄 3. `inventoryMatcher.ts` – Matcha produkter med inventory**
|
||
Använder **fuzzy matching** för att matcha produktnamn från PDF:en med användarens inventory.
|
||
|
||
```typescript
|
||
import { stringSimilarity } from 'string-similarity';
|
||
|
||
/**
|
||
* Matchar produkter från PDF:en med användarens inventory.
|
||
* @param products Produkter från PDF:en.
|
||
* @param userId Användarens ID.
|
||
* @returns Matchade produkter med inventory-status.
|
||
*/
|
||
export async function matchProductsWithInventory(products: any[], userId: number) {
|
||
const inventory = await prisma.userInventory.findMany({
|
||
where: { userId },
|
||
});
|
||
|
||
const inventoryNames = inventory.map((item) => ({
|
||
name: item.name.toLowerCase(),
|
||
id: item.id,
|
||
quantity: item.quantity,
|
||
category: item.category,
|
||
}));
|
||
|
||
return products.map((product) => {
|
||
const productName = product.name.toLowerCase();
|
||
const bestMatch = inventoryNames.reduce(
|
||
(best, item) => {
|
||
const similarity = stringSimilarity.compareTwoStrings(productName, item.name);
|
||
return similarity > best.similarity ? { ...item, similarity } : best;
|
||
},
|
||
{ name: '', similarity: 0, id: null, quantity: 0, category: null }
|
||
);
|
||
|
||
const inventoryItem = bestMatch.similarity > 0.6
|
||
? inventory.find((item) => item.id === bestMatch.id)
|
||
: null;
|
||
|
||
return {
|
||
...product,
|
||
inInventory: !!inventoryItem,
|
||
inventoryId: inventoryItem?.id || null,
|
||
inventoryQuantity: inventoryItem?.quantity || 0,
|
||
inventoryMatchSimilarity: bestMatch.similarity,
|
||
};
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📄 4. `recipeGenerator.ts` – Generera recept (AI eller mallar)**
|
||
Modulär funktion som använder **AI för premium-användare** och **mallar för gratis-användare**.
|
||
|
||
```typescript
|
||
import { MistralClient } from '@mistralai/mistralai';
|
||
import { prisma } from '../prisma'; // Antas att Prisma är konfigurerat
|
||
|
||
const mistral = new MistralClient({ apiKey: process.env.MISTRAL_API_KEY });
|
||
|
||
/**
|
||
* Genererar recept baserat på matchade produkter.
|
||
* @param matchedProducts Produkter matchade med inventory.
|
||
* @param userId Användarens ID.
|
||
* @param userPreferences Användarens preferenser.
|
||
* @returns Genererade recept.
|
||
*/
|
||
export async function generateRecipes(
|
||
matchedProducts: any[],
|
||
userId: number,
|
||
userPreferences: any = {}
|
||
): Promise<any[]> {
|
||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||
const isPremium = user?.is_premium || false;
|
||
|
||
if (isPremium) {
|
||
return generateRecipesWithAI(matchedProducts, userPreferences);
|
||
} else {
|
||
return generateRecipesFromTemplates(matchedProducts);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Genererar AI-recept för premium-användare.
|
||
*/
|
||
async function generateRecipesWithAI(matchedProducts: any[], userPreferences: any): Promise<any[]> {
|
||
const relevantProducts = matchedProducts.filter((p) => p.inInventory || p.offer);
|
||
if (relevantProducts.length === 0) return [];
|
||
|
||
const prompt = `
|
||
Du är en kock som skapar kostnadseffektiva, läckra och personliga recept.
|
||
Skapa **3 recept** baserat på följande produkter:
|
||
${JSON.stringify(relevantProducts, null, 2)}
|
||
|
||
Användarens preferenser:
|
||
- Diet: ${userPreferences.diet || 'Inga restriktioner'}
|
||
- Köksstil: ${userPreferences.cuisine || 'Nordisk'}
|
||
- Tid: ${userPreferences.time || 'Under 30 minuter'}
|
||
|
||
Recepten ska:
|
||
1. Använda produkter från inventory eller på erbjudande.
|
||
2. Vara lätt att laga.
|
||
3. Inkludera näringsinformation (uppskattad).
|
||
4. Vara på svenska.
|
||
|
||
Returnera recepten i JSON-format:
|
||
{
|
||
"recipes": [
|
||
{
|
||
"title": "Receptets titel",
|
||
"description": "Beskrivning",
|
||
"ingredients": [
|
||
{"name": "Produktnamn", "quantity": "Mängd", "fromInventory": true/false, "onOffer": true/false}
|
||
],
|
||
"instructions": ["Steg 1", "Steg 2"],
|
||
"cost": "Uppskattad kostnad",
|
||
"time": "Tillagningstid",
|
||
"nutritionalInfo": {"calories": "Kalorier", "protein": "Protein (g)"}
|
||
}
|
||
]
|
||
}
|
||
`;
|
||
|
||
try {
|
||
const response = await mistral.chat({
|
||
model: 'mistral-small-latest',
|
||
messages: [{ role: 'user', content: prompt }],
|
||
temperature: 0.7,
|
||
});
|
||
|
||
const jsonString = response.choices[0].message.content
|
||
.replace(/```json|```/g, '')
|
||
.trim();
|
||
|
||
const parsed = JSON.parse(jsonString);
|
||
return parsed.recipes || [];
|
||
} catch (error) {
|
||
console.error('AI-generering misslyckades:', error);
|
||
return generateRecipesFromTemplates(matchedProducts);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Genererar recept från mallar för gratis-användare.
|
||
*/
|
||
function generateRecipesFromTemplates(matchedProducts: any[]): any[] {
|
||
const recipes: any[] = [];
|
||
const relevantProducts = matchedProducts.filter((p) => p.inInventory || p.offer);
|
||
if (relevantProducts.length === 0) return recipes;
|
||
|
||
const productsByCategory: Record<string, any[]> = {};
|
||
relevantProducts.forEach((product) => {
|
||
const category = product.category || 'Okänt';
|
||
if (!productsByCategory[category]) productsByCategory[category] = [];
|
||
productsByCategory[category].push(product);
|
||
});
|
||
|
||
const templates: Record<string, any[]> = {
|
||
Fisk: [
|
||
{
|
||
title: 'Lax med potatis och grönsaker',
|
||
ingredients: [
|
||
{ name: 'Lax', category: 'Fisk', required: true },
|
||
{ name: 'Potatis', category: 'Grönsaker', required: true },
|
||
],
|
||
instructions: [
|
||
'Koka potatisen i 15 minuter.',
|
||
'Stek laxen i 5 minuter på varje sida.',
|
||
'Servera med grönsaker.',
|
||
],
|
||
time: '25 minuter',
|
||
},
|
||
],
|
||
Kött: [
|
||
{
|
||
title: 'Köttbullar med potatismos',
|
||
ingredients: [
|
||
{ name: 'Köttfärs', category: 'Kött', required: true },
|
||
{ name: 'Potatis', category: 'Grönsaker', required: true },
|
||
],
|
||
instructions: [
|
||
'Blanda köttfärs med salt och peppar.',
|
||
'Stek köttbullarna i en panna.',
|
||
'Koka och mosa potatisen.',
|
||
],
|
||
time: '30 minuter',
|
||
},
|
||
],
|
||
};
|
||
|
||
for (const [category, categoryTemplates] of Object.entries(templates)) {
|
||
const categoryProducts = productsByCategory[category] || [];
|
||
for (const template of categoryTemplates) {
|
||
const missingIngredients = template.ingredients.filter(
|
||
(ing: any) => ing.required && !categoryProducts.some((p) =>
|
||
p.name.toLowerCase().includes(ing.name.toLowerCase())
|
||
)
|
||
);
|
||
if (missingIngredients.length === 0) {
|
||
recipes.push({
|
||
title: template.title,
|
||
description: `Ett enkelt ${category.toLowerCase()}-recept.`,
|
||
ingredients: template.ingredients.map((ing: any) => {
|
||
const matchedProduct = categoryProducts.find((p) =>
|
||
p.name.toLowerCase().includes(ing.name.toLowerCase())
|
||
);
|
||
return {
|
||
name: matchedProduct?.name || ing.name,
|
||
quantity: matchedProduct?.weight || '1 portion',
|
||
fromInventory: matchedProduct?.inInventory || false,
|
||
onOffer: matchedProduct?.offer ? true : false,
|
||
};
|
||
}),
|
||
instructions: template.instructions,
|
||
time: template.time,
|
||
cost: calculateRecipeCost(template.ingredients, categoryProducts),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return recipes;
|
||
}
|
||
|
||
function calculateRecipeCost(ingredients: any[], products: any[]): string {
|
||
let totalCost = 0;
|
||
for (const ing of ingredients) {
|
||
const matchedProduct = products.find((p) =>
|
||
p.name.toLowerCase().includes(ing.name.toLowerCase())
|
||
);
|
||
if (matchedProduct?.price) {
|
||
const price = parseFloat(matchedProduct.price.replace(/[^\d.]/g, ''));
|
||
totalCost += price;
|
||
}
|
||
}
|
||
return `Ca ${totalCost.toFixed(2)} kr`;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📄 5. `pdfImportService.ts` – Fullständigt importflöde**
|
||
Haneterar hela flödet från PDF-import till receptgenerering.
|
||
|
||
```typescript
|
||
import { extractTextFromPDF } from './pdfTextExtractor';
|
||
import { parseWillysText } from './willysParser';
|
||
import { matchProductsWithInventory } from './inventoryMatcher';
|
||
import { generateRecipes } from './recipeGenerator';
|
||
import { prisma } from '../prisma';
|
||
|
||
/**
|
||
* Importerar en PDF och genererar recept.
|
||
* @param pdfPath Sökväg till PDF-filen.
|
||
* @param userId Användarens ID.
|
||
* @param userPreferences Användarens preferenser.
|
||
* @returns Resultat med produkter och recept.
|
||
*/
|
||
export async function importPDFAndGenerateRecipes(
|
||
pdfPath: string,
|
||
userId: number,
|
||
userPreferences: any = {}
|
||
) {
|
||
try {
|
||
// 1. Extrahera text
|
||
const text = await extractTextFromPDF(pdfPath);
|
||
|
||
// 2. Parsa texten
|
||
const products = parseWillysText(text);
|
||
|
||
// 3. Matcha med inventory
|
||
const matchedProducts = await matchProductsWithInventory(products, userId);
|
||
|
||
// 4. Generera recept
|
||
const recipes = await generateRecipes(matchedProducts, userId, userPreferences);
|
||
|
||
// 5. Spara recepten
|
||
const savedRecipes = [];
|
||
for (const recipe of recipes) {
|
||
const savedRecipe = await prisma.encryptedData.create({
|
||
data: {
|
||
encrypted_data: JSON.stringify(recipe),
|
||
owner_id: userId,
|
||
created_at: new Date(),
|
||
is_generated: true,
|
||
source: user.is_premium ? 'AI (Premium)' : 'Mallar (Gratis)',
|
||
},
|
||
});
|
||
savedRecipes.push(savedRecipe);
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
products: matchedProducts,
|
||
recipes: savedRecipes,
|
||
isPremium: await isUserPremium(userId),
|
||
};
|
||
} catch (error) {
|
||
console.error('Fel vid import:', error);
|
||
return { success: false, error: error instanceof Error ? error.message : 'Okänt fel' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Kontrollerar om en användare är premium.
|
||
*/
|
||
async function isUserPremium(userId: number): Promise<boolean> {
|
||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||
if (!user) return false;
|
||
|
||
if (user.premium_expiry_date && new Date(user.premium_expiry_date) < new Date()) {
|
||
await prisma.user.update({
|
||
where: { id: userId },
|
||
data: { is_premium: false },
|
||
});
|
||
return false;
|
||
}
|
||
|
||
return user.is_premium;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📄 6. API-Endpoints**
|
||
### **🔹 PDF-import (`/api/import/pdf`)**
|
||
```typescript
|
||
import express from 'express';
|
||
import multer from 'multer';
|
||
import { importPDFAndGenerateRecipes } from '../services/pdfImportService';
|
||
|
||
const router = express.Router();
|
||
const upload = multer({ dest: 'uploads/' });
|
||
|
||
router.post('/import/pdf', upload.single('pdf'), async (req, res) => {
|
||
try {
|
||
if (!req.file) {
|
||
return res.status(400).json({ error: 'Ingen PDF-fil uppladdad.' });
|
||
}
|
||
|
||
const userId = req.user.id;
|
||
const userPreferences = req.body.preferences || {};
|
||
|
||
const result = await importPDFAndGenerateRecipes(
|
||
req.file.path,
|
||
userId,
|
||
userPreferences
|
||
);
|
||
|
||
fs.unlinkSync(req.file.path); // Rensa upp
|
||
|
||
if (!result.success) {
|
||
return res.status(500).json({ error: result.error });
|
||
}
|
||
|
||
res.json(result);
|
||
} catch (error) {
|
||
console.error('Fel vid PDF-import:', error);
|
||
res.status(500).json({ error: 'Kunde inte importera PDF:en.' });
|
||
}
|
||
});
|
||
|
||
export default router;
|
||
```
|
||
|
||
### **🔹 Premium-uppgradering (`/api/user/upgrade`)**
|
||
```typescript
|
||
router.post('/upgrade-premium', async (req, res) => {
|
||
try {
|
||
const userId = req.user.id;
|
||
// Antas att betalning är verifierad (t.ex. via Stripe)
|
||
const expiryDate = new Date();
|
||
expiryDate.setMonth(expiryDate.getMonth() + 1); // 1 månad premium
|
||
|
||
await prisma.user.update({
|
||
where: { id: userId },
|
||
data: {
|
||
is_premium: true,
|
||
premium_expiry_date: expiryDate,
|
||
},
|
||
});
|
||
|
||
res.json({ success: true, premiumExpiryDate: expiryDate });
|
||
} catch (error) {
|
||
console.error('Fel vid uppgradering:', error);
|
||
res.status(500).json({ error: 'Kunde inte uppgradera till premium.' });
|
||
}
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📄 7. Frontend-Integrering (Exempel: React)**
|
||
### **🔹 Visa premium-status**
|
||
```tsx
|
||
import { useState, useEffect } from 'react';
|
||
|
||
function PremiumStatus({ user }) {
|
||
const [isPremium, setIsPremium] = useState(user.is_premium);
|
||
|
||
useEffect(() => {
|
||
setIsPremium(user.is_premium);
|
||
}, [user]);
|
||
|
||
return (
|
||
<div className="premium-status">
|
||
{isPremium ? (
|
||
<div className="premium-badge">
|
||
✅ Premium (gäller till: {new Date(user.premium_expiry_date).toLocaleDateString('sv-SE')})
|
||
<p>Du har tillgång till AI-genererade recept!</p>
|
||
</div>
|
||
) : (
|
||
<div className="upgrade-prompt">
|
||
🔒 <a href="/upgrade">Uppgradera till premium</a> för AI-recept!
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### **🔹 PDF-import-formulär**
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import axios from 'axios';
|
||
|
||
function PDFImportForm({ user }) {
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [result, setResult] = useState<any>(null);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!file) return;
|
||
|
||
setIsLoading(true);
|
||
const formData = new FormData();
|
||
formData.append('pdf', file);
|
||
formData.append('preferences', JSON.stringify({
|
||
diet: 'Inga restriktioner',
|
||
cuisine: 'Nordisk',
|
||
}));
|
||
|
||
try {
|
||
const response = await axios.post('/api/import/pdf', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
});
|
||
setResult(response.data);
|
||
} catch (error) {
|
||
console.error('Fel vid uppladdning:', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<h2>Importera veckoblad</h2>
|
||
<form onSubmit={handleSubmit}>
|
||
<input
|
||
type="file"
|
||
accept=".pdf"
|
||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||
required
|
||
/>
|
||
<button type="submit" disabled={isLoading}>
|
||
{isLoading ? 'Laddar upp...' : 'Importera'}
|
||
</button>
|
||
</form>
|
||
|
||
{result && (
|
||
<div>
|
||
<h3>Resultat</h3>
|
||
<p>Hittade {result.products?.length || 0} produkter.</p>
|
||
<p>Genererade {result.recipes?.length || 0} recept.</p>
|
||
{result.recipes?.map((recipe: any, index: number) => (
|
||
<div key={index} className="recipe-card">
|
||
<h4>{recipe.title}</h4>
|
||
<p>{recipe.description}</p>
|
||
<ul>
|
||
{recipe.ingredients?.map((ing: any, i: number) => (
|
||
<li key={i}>
|
||
{ing.quantity} {ing.name} {ing.onOffer && '🔥'}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📌 Miljövariabler (`.env`)**
|
||
```env
|
||
# Mistral API-nyckel (för premium-funktioner)
|
||
MISTRAL_API_KEY=din_api_nyckel_här
|
||
|
||
# Databas-URL
|
||
DATABASE_URL=postgresql://user:password@localhost:5432/db_name
|
||
|
||
# Session-hemlighet (för autentisering)
|
||
SESSION_SECRET=din_hemliga_nyckel_här
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## **📌 Paket som behövs (`package.json`)**
|
||
```json
|
||
{
|
||
"dependencies": {
|
||
"express": "^4.18.2",
|
||
"multer": "^1.4.5-lts.1",
|
||
"pdf-parse": "^1.1.1",
|
||
"tesseract.js": "^4.0.2",
|
||
"@mistralai/mistralai": "^0.0.1",
|
||
"@prisma/client": "^5.0.0",
|
||
"prisma": "^5.0.0",
|
||
"string-similarity": "^4.0.4",
|
||
"typescript": "^5.0.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
---
|
||
## **📌 Steg-för-steg Implementeringsguide**
|
||
|
||
### **1. Förbered databasen**
|
||
- Kör migrationskommandot för att lägga till `is_premium` och `premium_expiry_date` i `User`-tabellen:
|
||
```bash
|
||
npx prisma migrate dev --name add_premium_fields
|
||
```
|
||
|
||
### **2. Installera beroenden**
|
||
```bash
|
||
npm install express multer pdf-parse tesseract.js @mistralai/mistralai string-similarity
|
||
npm install --save-dev typescript @types/node @types/express @types/multer prisma
|
||
```
|
||
|
||
### **3. Konfigurera Prisma**
|
||
- Skapa eller uppdatera `schema.prisma` med `User`-modellen (se ovan).
|
||
- Kör `npx prisma generate` för att generera Prisma-klienten.
|
||
|
||
### **4. Skapa filerna**
|
||
- Skapa mappen `src/services/` och lägg till filerna:
|
||
- `pdfTextExtractor.ts`
|
||
- `willysParser.ts`
|
||
- `inventoryMatcher.ts`
|
||
- `recipeGenerator.ts`
|
||
- `pdfImportService.ts`
|
||
- Skapa mappen `src/api/` och lägg till:
|
||
- `pdfImport.ts`
|
||
- `premium.ts`
|
||
|
||
### **5. Konfigurera Express-server**
|
||
- Skapa en `server.ts` för att starta din Express-server:
|
||
```typescript
|
||
import express from 'express';
|
||
import pdfImportRouter from './api/pdfImport';
|
||
import premiumRouter from './api/premium';
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
|
||
// API-endpoints
|
||
app.use('/api/import', pdfImportRouter);
|
||
app.use('/api/user', premiumRouter);
|
||
|
||
const PORT = process.env.PORT || 3000;
|
||
app.listen(PORT, () => {
|
||
console.log(`Server körs på port ${PORT}`);
|
||
});
|
||
```
|
||
|
||
### **6. Testa flödet**
|
||
1. **Ladda upp en PDF** (t.ex. Willys veckoblad) via `/api/import/pdf`.
|
||
2. **Kontrollera att recept genereras** (mallar för gratis, AI för premium).
|
||
3. **Testa premium-uppgraderingen** via `/api/user/upgrade`.
|
||
|
||
---
|
||
---
|
||
## **📌 Säkerhetsöverväganden**
|
||
1. **API-nycklar**:
|
||
- Lagra `MISTRAL_API_KEY` i miljövariabler (aldrig i koden).
|
||
2. **Premium-status**:
|
||
- Kontrollera alltid `is_premium` innan AI-anrop.
|
||
3. **Filuppladdning**:
|
||
- Validera filtypen (endast PDF).
|
||
- Begränsa filstorleken (t.ex. max 10MB).
|
||
4. **Autentisering**:
|
||
- Se till att användaren är inloggad innan de kan ladda upp PDF:er eller uppgradera.
|
||
|
||
---
|
||
---
|
||
## **📌 Felsökning**
|
||
| Problem | Lösning |
|
||
|---------|---------|
|
||
| **PDF:en läses inte in** | Kontrollera att filen är en giltig PDF. Använd `Tesseract.js` som fallback. |
|
||
| **Regex matchar inte** | Justera regex-mönstren baserat på PDF:ens struktur. |
|
||
| **AI-generering misslyckas** | Fallback till mallar. Kontrollera API-nyckeln. |
|
||
| **Premium-status uppdateras inte** | Kontrollera att `is_premium` och `premium_expiry_date` uppdateras korrekt. |
|
||
| **Inga recept genereras** | Kontrollera att produkterna matchas korrekt med inventory. |
|
||
|
||
---
|
||
---
|
||
## **🚀 Nästa steg**
|
||
1. **Testa med riktiga PDF:er** (t.ex. Willys veckoblad).
|
||
2. **Justera regex-mönstren** för att fånga fler produktdetaljer.
|
||
3. **Lägg till fler receptmallar** för olika kategorier.
|
||
4. **Implementera en betalningslösning** (t.ex. Stripe) för premium-uppgraderingar.
|
||
5. **Monitorera användningen** av premium-funktioner.
|
||
|
||
---
|
||
---
|
||
## **📌 Exempel på betalningsintegrering (Stripe)**
|
||
Om du vill lägga till **verklig betalning** för premium-uppgraderingar, kan du använda **Stripe**:
|
||
|
||
### **1. Installera Stripe**
|
||
```bash
|
||
npm install stripe @stripe/stripe-js
|
||
```
|
||
|
||
### **2. Skapa en Stripe-endpoint**
|
||
```typescript
|
||
import Stripe from 'stripe';
|
||
|
||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||
apiVersion: '2023-10-16',
|
||
});
|
||
|
||
router.post('/create-checkout-session', async (req, res) => {
|
||
const userId = req.user.id;
|
||
|
||
const session = await stripe.checkout.sessions.create({
|
||
payment_method_types: ['card'],
|
||
line_items: [
|
||
{
|
||
price_data: {
|
||
currency: 'sek',
|
||
product_data: {
|
||
name: 'Premium-medlemskap (1 månad)',
|
||
},
|
||
unit_amount: 9900, // 99 kr
|
||
},
|
||
quantity: 1,
|
||
},
|
||
],
|
||
mode: 'payment',
|
||
success_url: `${process.env.FRONTEND_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||
cancel_url: `${process.env.FRONTEND_URL}/cancel`,
|
||
metadata: { userId },
|
||
});
|
||
|
||
res.json({ url: session.url });
|
||
});
|
||
```
|
||
|
||
### **3. Hantera framgångsrik betalning**
|
||
```typescript
|
||
router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
|
||
const sig = req.headers['stripe-signature'] as string;
|
||
const event = stripe.webhooks.constructEvent(
|
||
req.body,
|
||
sig,
|
||
process.env.STRIPE_WEBHOOK_SECRET!
|
||
);
|
||
|
||
if (event.type === 'checkout.session.completed') {
|
||
const session = event.data.object as Stripe.Checkout.Session;
|
||
const userId = parseInt(session.metadata.userId);
|
||
|
||
const expiryDate = new Date();
|
||
expiryDate.setMonth(expiryDate.getMonth() + 1);
|
||
|
||
await prisma.user.update({
|
||
where: { id: userId },
|
||
data: {
|
||
is_premium: true,
|
||
premium_expiry_date: expiryDate,
|
||
},
|
||
});
|
||
}
|
||
|
||
res.json({ received: true });
|
||
});
|
||
```
|
||
|
||
---
|
||
---
|
||
## **📌 Sammanfattning**
|
||
Denna plan ger dig en **komplett lösning** för att:
|
||
1. **Importera PDF:er** (t.ex. Willys veckoblad).
|
||
2. **Extrahera och strukturera data** (med regex och OCR).
|
||
3. **Matcha produkter med inventory** (fuzzy matching).
|
||
4. **Generera recept**:
|
||
- **Gratis**: Med mallar.
|
||
- **Premium**: Med AI (Mistral).
|
||
5. **Hantera premium-uppgraderingar** (med Stripe).
|
||
|
||
---
|
||
---
|
||
## **📥 Hur du använder denna plan i VS Code**
|
||
1. **Spara denna text** som `premium_recipe_feature_plan.md` i din projektmapp.
|
||
2. **Öppna filen i VS Code**.
|
||
3. **Använd VS Code-chatten** (t.ex. med **Mistral Vibe** eller **Continue**) för att:
|
||
- **Fråga om förtydliganden** för specifika steg.
|
||
- **Be om hjälp** med att implementera enskilda filer.
|
||
- **Debugga** om något inte fungerar.
|
||
4. **Kopiera och klistra in kodsnuttarna** i dina egna filer.
|
||
|
||
---
|
||
---
|
||
## **💡 Tips för VS Code**
|
||
- **Använd `Ctrl+Shift+P`** för att öppna kommandopaletten och köra `Prisma: Generate Client`.
|
||
- **Installera ESLint** för att fånga syntaxfel tidigt.
|
||
- **Använd Git** för att spåra dina ändringar:
|
||
```bash
|
||
git init
|
||
git add .
|
||
git commit -m "Lade till premium-receptfunktion"
|
||
```
|
||
```
|
||
|
||
---
|
||
**Spara denna text som `premium_recipe_feature_plan.md` och ladda upp den i VS Code-chatten när du behöver hjälp med implementeringen!** 🚀 |