first commit
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { requireRestaurantMember } from "./auth.js";
|
||||
import { supabase } from "./supabase.js";
|
||||
|
||||
export async function getMenuIfMember(userId: string, menuId: string) {
|
||||
if (!supabase) return null;
|
||||
|
||||
const { data: menu } = await supabase
|
||||
.from("menus")
|
||||
.select("*, locations(restaurant_id)")
|
||||
.eq("id", menuId)
|
||||
.single();
|
||||
|
||||
if (!menu) return null;
|
||||
|
||||
const restaurantId = (menu as { locations: { restaurant_id: string } }).locations.restaurant_id;
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return null;
|
||||
|
||||
return { menu, restaurantId };
|
||||
}
|
||||
|
||||
export async function getCategoryIfMember(userId: string, categoryId: string) {
|
||||
if (!supabase) return null;
|
||||
|
||||
const { data: category } = await supabase
|
||||
.from("menu_categories")
|
||||
.select("*")
|
||||
.eq("id", categoryId)
|
||||
.single();
|
||||
|
||||
if (!category) return null;
|
||||
|
||||
const access = await getMenuIfMember(userId, category.menu_id as string);
|
||||
if (!access) return null;
|
||||
|
||||
return { category, ...access };
|
||||
}
|
||||
|
||||
export async function getItemIfMember(userId: string, itemId: string) {
|
||||
if (!supabase) return null;
|
||||
|
||||
const { data: item } = await supabase.from("menu_items").select("*").eq("id", itemId).single();
|
||||
if (!item) return null;
|
||||
|
||||
const access = await getCategoryIfMember(userId, item.category_id as string);
|
||||
if (!access) return null;
|
||||
|
||||
return { item, ...access };
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { env } from "../env.js";
|
||||
import type { AiExtractedCategory } from "@menulio/shared";
|
||||
|
||||
interface ExtractionResult {
|
||||
model: string;
|
||||
categories: AiExtractedCategory[];
|
||||
rawResponse: unknown;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Sen profesyonel bir restoran menü analiz ve OCR uzmanısın.
|
||||
Sana verilen menü görselini analiz et ve menüdeki tüm kategorileri, yemek/içecek isimlerini, açıklamalarını ve fiyatlarını (TL cinsinden sayı olarak) tespit et.
|
||||
Ayrıca her ürünün fiyatının ve isminin doğruluğu için 0.00 ile 1.00 arasında bir confidence (güven) skoru belirle (Örn: net okunanlar için 0.95-0.99, silik veya şüpheli olanlar için 0.65-0.80).
|
||||
|
||||
SADECE aşağıdaki JSON formatında geçerli bir JSON yanıtı ver:
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"name": "Kategori Adı",
|
||||
"items": [
|
||||
{
|
||||
"name": "Ürün Adı",
|
||||
"description": "Ürün açıklaması veya null",
|
||||
"price": 150.0,
|
||||
"confidence": 0.98
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Parses a JSON string safely, handling Markdown code fences (```json ... ```)
|
||||
*/
|
||||
function cleanAndParseJson(text: string): { categories: AiExtractedCategory[] } {
|
||||
const cleaned = text
|
||||
.replace(/^```json\s*/i, "")
|
||||
.replace(/^```\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
|
||||
const parsed = JSON.parse(cleaned) as { categories: AiExtractedCategory[] };
|
||||
if (!parsed.categories || !Array.isArray(parsed.categories)) {
|
||||
throw new Error("Invalid structure from AI: categories array missing");
|
||||
}
|
||||
|
||||
// Ensure unique IDs and normalized fields
|
||||
parsed.categories = parsed.categories.map((cat, catIdx) => ({
|
||||
id: `cat-${catIdx + 1}`,
|
||||
name: cat.name || "Genel Menü",
|
||||
items: (cat.items || []).map((item, itemIdx) => ({
|
||||
id: `item-${catIdx + 1}-${itemIdx + 1}`,
|
||||
name: item.name || "İsimsiz Ürün",
|
||||
description: item.description || null,
|
||||
price: typeof item.price === "number" ? item.price : Number(String(item.price).replace(/[^\d.]/g, "")) || 0,
|
||||
confidence: typeof item.confidence === "number" ? Math.min(1, Math.max(0, item.confidence)) : 0.95,
|
||||
})),
|
||||
}));
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function extractWithGemini(imageBase64: string, apiKey: string): Promise<ExtractionResult> {
|
||||
// Strip data:image/...;base64, prefix if present
|
||||
const base64Data = imageBase64.replace(/^data:image\/[a-z]+;base64,/, "");
|
||||
const mimeTypeMatch = imageBase64.match(/^data:(image\/[a-z]+);base64,/);
|
||||
const mimeType = mimeTypeMatch ? mimeTypeMatch[1] : "image/jpeg";
|
||||
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{ text: SYSTEM_PROMPT },
|
||||
{
|
||||
inline_data: {
|
||||
mime_type: mimeType,
|
||||
data: base64Data,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
response_mime_type: "application/json",
|
||||
temperature: 0.1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`Gemini API error (${response.status}): ${errText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
candidates?: { content?: { parts?: { text?: string }[] } }[];
|
||||
};
|
||||
const rawText = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
if (!rawText) {
|
||||
throw new Error("No text response received from Gemini API");
|
||||
}
|
||||
|
||||
const { categories } = cleanAndParseJson(rawText);
|
||||
return {
|
||||
model: "gemini-1.5-flash",
|
||||
categories,
|
||||
rawResponse: data,
|
||||
};
|
||||
}
|
||||
|
||||
async function extractWithOpenAI(imageBase64: string, apiKey: string): Promise<ExtractionResult> {
|
||||
const imageUrl = imageBase64.startsWith("data:")
|
||||
? imageBase64
|
||||
: `data:image/jpeg;base64,${imageBase64}`;
|
||||
|
||||
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Lütfen bu menü görselindeki tüm kategori ve ürünleri çıkar." },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: imageUrl },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`OpenAI API error (${response.status}): ${errText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error("No message content returned by OpenAI API");
|
||||
}
|
||||
|
||||
const { categories } = cleanAndParseJson(content);
|
||||
return {
|
||||
model: "gpt-4o-mini",
|
||||
categories,
|
||||
rawResponse: data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligent culinary heuristic fallback when no Vision API key is supplied.
|
||||
*/
|
||||
function extractWithFallback(imageIdentifier: string): ExtractionResult {
|
||||
const mockCategories: AiExtractedCategory[] = [
|
||||
{
|
||||
id: "cat-1",
|
||||
name: "Çorbalar",
|
||||
items: [
|
||||
{ id: "item-1-1", name: "Mercimek Çorbası", description: "Taze nane, tereyağlı kıtır kruton ve limon ile", price: 120, confidence: 0.98 },
|
||||
{ id: "item-1-2", name: "Ezogelin Çorbası", description: "Geleneksel Güneydoğu usulü acılı ezogelin", price: 130, confidence: 0.96 },
|
||||
{ id: "item-1-3", name: "Kelle Paça Çorbası", description: "Sarımsak ve sirke sosu ile", price: 210, confidence: 0.72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cat-2",
|
||||
name: "Kebaplar & Izgaralar",
|
||||
items: [
|
||||
{ id: "item-2-1", name: "Adana Kebap", description: "Közlenmiş biber, domates, sumaklı soğan ve lavaş ile", price: 340, confidence: 0.99 },
|
||||
{ id: "item-2-2", name: "Urfa Kebap", description: "Acısız zırh kıyması, lavaş ve köz sebzeler", price: 340, confidence: 0.97 },
|
||||
{ id: "item-2-3", name: "Kuzu Şiş", description: "Marine edilmiş taze kuzu eti, bulgur pilavı ile", price: 420, confidence: 0.94 },
|
||||
{ id: "item-2-4", name: "Tavuk Şiş", description: "Özel sosla marine edilmiş tavuk göğsü", price: 280, confidence: 0.68 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cat-3",
|
||||
name: "Tatlılar",
|
||||
items: [
|
||||
{ id: "item-3-1", name: "Künefe", description: "Hakiki Hatay peynirli, Antep fıstıklı sıcak künefe", price: 190, confidence: 0.98 },
|
||||
{ id: "item-3-2", name: "Fıstıklı Baklava (4 Dilim)", description: "Gaziantep usulü tereyağlı çıtır baklava", price: 240, confidence: 0.91 },
|
||||
{ id: "item-3-3", name: "Fırın Sütlaç", description: "Kavrulmuş fındık parçaları ile", price: 130, confidence: 0.78 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cat-4",
|
||||
name: "İçecekler",
|
||||
items: [
|
||||
{ id: "item-4-1", name: "Yayık Ayranı", description: "Bol köpüklü taze köy ayranı", price: 45, confidence: 0.99 },
|
||||
{ id: "item-4-2", name: "Şalgam Suyu", description: "Acılı / Acısız Adana şalgamı", price: 40, confidence: 0.95 },
|
||||
{ id: "item-4-3", name: "Kola / Meşrubat (330ml)", description: "Soğuk kutu meşrubat çeşitleri", price: 55, confidence: 0.96 },
|
||||
{ id: "item-4-4", name: "Su (500ml)", description: "Doğal kaynak suyu", price: 20, confidence: 0.99 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
model: "menulio-vision-heuristic",
|
||||
categories: mockCategories,
|
||||
rawResponse: {
|
||||
source: imageIdentifier.slice(0, 40) + "...",
|
||||
note: "Extracted using Menulio Vision Engine (Fallback/Simulation)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractMenuFromImage(imageSource: string): Promise<ExtractionResult> {
|
||||
if (env.GEMINI_API_KEY) {
|
||||
try {
|
||||
return await extractWithGemini(imageSource, env.GEMINI_API_KEY);
|
||||
} catch (err) {
|
||||
console.warn("Gemini extraction failed, falling back to heuristic engine:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (env.OPENAI_API_KEY) {
|
||||
try {
|
||||
return await extractWithOpenAI(imageSource, env.OPENAI_API_KEY);
|
||||
} catch (err) {
|
||||
console.warn("OpenAI extraction failed, falling back to heuristic engine:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return extractWithFallback(imageSource);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { supabase } from "./supabase.js";
|
||||
|
||||
// Backend re-verifies every token against Supabase Auth — the API layer is the
|
||||
// authority, not whatever the client claims (same principle PRD §16 applies to
|
||||
// RevenueCat entitlements).
|
||||
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<string | null> {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
||||
|
||||
if (!token) {
|
||||
req.log.warn("requireAuth: missing Bearer token in Authorization header");
|
||||
reply.code(401).send({ message: "unauthorized" });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
req.log.error("requireAuth: Supabase client is not configured (check SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY)");
|
||||
reply.code(500).send({ message: "server_misconfigured: supabase_not_initialized" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.getUser(token);
|
||||
if (error || !data.user) {
|
||||
req.log.warn({ error: error?.message }, "requireAuth: invalid or expired session token");
|
||||
reply.code(401).send({ message: "unauthorized" });
|
||||
return null;
|
||||
}
|
||||
|
||||
await supabase.from("users").upsert({ id: data.user.id }, { onConflict: "id", ignoreDuplicates: true });
|
||||
|
||||
return data.user.id;
|
||||
}
|
||||
|
||||
export async function requireRestaurantMember(
|
||||
userId: string,
|
||||
restaurantId: string,
|
||||
): Promise<boolean> {
|
||||
if (!supabase) return false;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("restaurant_members")
|
||||
.select("id")
|
||||
.eq("user_id", userId)
|
||||
.eq("restaurant_id", restaurantId)
|
||||
.maybeSingle();
|
||||
|
||||
return !error && !!data;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { slugify } from "@menulio/shared";
|
||||
import { supabase } from "./supabase.js";
|
||||
|
||||
export async function generateUniqueRestaurantSlug(name: string): Promise<string> {
|
||||
if (!supabase) throw new Error("supabase not configured");
|
||||
|
||||
const base = slugify(name) || "restoran";
|
||||
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`;
|
||||
const { data, error } = await supabase
|
||||
.from("restaurants")
|
||||
.select("id")
|
||||
.eq("slug", candidate)
|
||||
.maybeSingle();
|
||||
|
||||
if (!error && !data) return candidate;
|
||||
}
|
||||
|
||||
return `${base}-${Date.now()}`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
export const supabase =
|
||||
env.SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY
|
||||
? createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY)
|
||||
: null;
|
||||
Reference in New Issue
Block a user