first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
PORT=3001
|
||||
SUPABASE_URL=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
REVENUECAT_WEBHOOK_SECRET=
|
||||
ROOT_DOMAIN=menulio.app
|
||||
@@ -0,0 +1,40 @@
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @menulio/api...
|
||||
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY apps/api ./apps/api
|
||||
|
||||
RUN pnpm --filter @menulio/api build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3001
|
||||
ENV HOST="0.0.0.0"
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 fastify
|
||||
|
||||
COPY --from=builder /app/package.json /app/pnpm-lock.yaml* /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/packages/shared ./packages/shared
|
||||
COPY --from=builder /app/apps/api ./apps/api
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
USER fastify
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@menulio/api",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=.env src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node --env-file=.env dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@menulio/shared": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.45.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"fastify": "^5.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.resolve(__dirname, "../.env") });
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(3001),
|
||||
SUPABASE_URL: z.string().url().optional(),
|
||||
SUPABASE_SERVICE_ROLE_KEY: z.string().optional(),
|
||||
REVENUECAT_WEBHOOK_SECRET: z.string().optional(),
|
||||
ROOT_DOMAIN: z.string().default("menul.io"),
|
||||
PUBLIC_WEB_URL: z.string().default(process.env.PUBLIC_WEB_URL ?? "http://localhost:3000"),
|
||||
GEMINI_API_KEY: z.string().optional(),
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import cors from "@fastify/cors";
|
||||
import Fastify from "fastify";
|
||||
import { env } from "./env.js";
|
||||
import { aiImportsRoutes } from "./routes/ai-imports.js";
|
||||
import { analyticsRoutes } from "./routes/analytics.js";
|
||||
import { domainsRoutes } from "./routes/domains.js";
|
||||
import { menuCategoriesRoutes } from "./routes/menu-categories.js";
|
||||
import { menuItemsRoutes } from "./routes/menu-items.js";
|
||||
import { menusRoutes } from "./routes/menus.js";
|
||||
import { meRoutes } from "./routes/me.js";
|
||||
import { qrRoutes } from "./routes/qr.js";
|
||||
import { restaurantsRoutes } from "./routes/restaurants.js";
|
||||
import { subscriptionRoutes } from "./routes/subscription.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors);
|
||||
|
||||
// Clients (mobile fetch/axios in particular) commonly send
|
||||
// `Content-Type: application/json` on bodyless POSTs — treat an empty body
|
||||
// as `{}` instead of the default 400.
|
||||
app.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
|
||||
if (!body) {
|
||||
done(null, {});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
done(null, JSON.parse(body as string));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
|
||||
await app.register(meRoutes);
|
||||
await app.register(restaurantsRoutes);
|
||||
await app.register(menusRoutes);
|
||||
await app.register(menuCategoriesRoutes);
|
||||
await app.register(menuItemsRoutes);
|
||||
await app.register(qrRoutes);
|
||||
await app.register(aiImportsRoutes);
|
||||
await app.register(domainsRoutes);
|
||||
await app.register(analyticsRoutes);
|
||||
await app.register(subscriptionRoutes);
|
||||
|
||||
app
|
||||
.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||
.catch((err) => {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||
import { extractMenuFromImage } from "../lib/ai-scanner.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
import type { AiExtractedCategory, AiImportResponse } from "@menulio/shared";
|
||||
|
||||
const importAiSchema = z.object({
|
||||
image: z.string().min(1, "Görsel verisi zorunludur"),
|
||||
});
|
||||
|
||||
const applyAiImportSchema = z.object({
|
||||
menuId: z.string().uuid(),
|
||||
categories: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
items: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().nullable().optional(),
|
||||
price: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const aiImportsRoutes: FastifyPluginAsync = async (app) => {
|
||||
/**
|
||||
* POST /menus/:id/import-ai
|
||||
* Runs the AI Menu Scanner pipeline on an uploaded menu photo
|
||||
*/
|
||||
app.post("/menus/:id/import-ai", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: menuId } = req.params as { id: string };
|
||||
|
||||
// Resolve restaurant from menu
|
||||
const { data: menuRow, error: menuError } = await supabase
|
||||
.from("menus")
|
||||
.select("id, location_id, locations!inner(restaurant_id)")
|
||||
.eq("id", menuId)
|
||||
.single();
|
||||
|
||||
if (menuError || !menuRow) {
|
||||
return reply.code(404).send({ message: "Menü bulunamadı" });
|
||||
}
|
||||
|
||||
const restaurantId = (menuRow.locations as unknown as { restaurant_id: string }).restaurant_id;
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "Bu menü için yetkiniz yok" });
|
||||
|
||||
const parsed = importAiSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "Geçersiz istek", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { image } = parsed.data;
|
||||
|
||||
// 1. Create audit record in ai_imports
|
||||
const { data: importRecord, error: insertError } = await supabase
|
||||
.from("ai_imports")
|
||||
.insert({
|
||||
restaurant_id: restaurantId,
|
||||
source_image: image.length > 200 ? `${image.slice(0, 100)}...[base64_data]` : image,
|
||||
status: "processing",
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError || !importRecord) {
|
||||
req.log.error(insertError);
|
||||
return reply.code(500).send({ message: "AI import kaydı oluşturulamadı" });
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Run AI extraction engine
|
||||
const extraction = await extractMenuFromImage(image);
|
||||
|
||||
// 3. Save extracted items to ai_import_items
|
||||
const itemsToInsert: {
|
||||
ai_import_id: string;
|
||||
category_name: string;
|
||||
item_name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
confidence: number;
|
||||
}[] = [];
|
||||
|
||||
for (const cat of extraction.categories) {
|
||||
for (const item of cat.items) {
|
||||
itemsToInsert.push({
|
||||
ai_import_id: importRecord.id,
|
||||
category_name: cat.name,
|
||||
item_name: item.name,
|
||||
description: item.description ?? null,
|
||||
price: item.price,
|
||||
confidence: item.confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsToInsert.length > 0) {
|
||||
await supabase.from("ai_import_items").insert(itemsToInsert);
|
||||
}
|
||||
|
||||
// 4. Update import status to completed
|
||||
await supabase
|
||||
.from("ai_imports")
|
||||
.update({
|
||||
status: "completed",
|
||||
model: extraction.model,
|
||||
raw_response: extraction.rawResponse as Record<string, unknown>,
|
||||
processed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", importRecord.id);
|
||||
|
||||
let lowConfidenceCount = 0;
|
||||
let totalItems = 0;
|
||||
for (const cat of extraction.categories) {
|
||||
for (const itm of cat.items) {
|
||||
totalItems++;
|
||||
if (itm.confidence < 0.85) {
|
||||
lowConfidenceCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response: AiImportResponse = {
|
||||
importId: importRecord.id,
|
||||
status: "completed",
|
||||
model: extraction.model,
|
||||
categories: extraction.categories,
|
||||
totalItems,
|
||||
lowConfidenceCount,
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
} catch (err) {
|
||||
req.log.error(err);
|
||||
await supabase
|
||||
.from("ai_imports")
|
||||
.update({
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "AI analizi sırasında hata oluştu",
|
||||
})
|
||||
.eq("id", importRecord.id);
|
||||
|
||||
return reply.code(500).send({
|
||||
message: "Menü fotoğrafı analiz edilirken bir hata oluştu",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /ai-imports/:id
|
||||
* Fetch an existing AI import and its extracted items
|
||||
*/
|
||||
app.get("/ai-imports/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const { data: importRecord, error: importError } = await supabase
|
||||
.from("ai_imports")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (importError || !importRecord) {
|
||||
return reply.code(404).send({ message: "Import kaydı bulunamadı" });
|
||||
}
|
||||
|
||||
const isMember = await requireRestaurantMember(userId, importRecord.restaurant_id);
|
||||
if (!isMember) return reply.code(403).send({ message: "Yetkiniz yok" });
|
||||
|
||||
const { data: rawItems } = await supabase
|
||||
.from("ai_import_items")
|
||||
.select("*")
|
||||
.eq("ai_import_id", id);
|
||||
|
||||
// Group items into categories
|
||||
const categoryMap = new Map<string, AiExtractedCategory>();
|
||||
let totalItems = 0;
|
||||
let lowConfidenceCount = 0;
|
||||
|
||||
for (const item of rawItems ?? []) {
|
||||
totalItems++;
|
||||
if ((item.confidence ?? 1) < 0.85) lowConfidenceCount++;
|
||||
|
||||
const catName = item.category_name || "Genel";
|
||||
if (!categoryMap.has(catName)) {
|
||||
categoryMap.set(catName, {
|
||||
id: `cat-${categoryMap.size + 1}`,
|
||||
name: catName,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
categoryMap.get(catName)!.items.push({
|
||||
id: item.id,
|
||||
name: item.item_name,
|
||||
description: item.description,
|
||||
price: Number(item.price ?? 0),
|
||||
confidence: Number(item.confidence ?? 0.95),
|
||||
});
|
||||
}
|
||||
|
||||
const response: AiImportResponse = {
|
||||
importId: importRecord.id,
|
||||
status: importRecord.status,
|
||||
model: importRecord.model,
|
||||
categories: Array.from(categoryMap.values()),
|
||||
totalItems,
|
||||
lowConfidenceCount,
|
||||
};
|
||||
|
||||
return reply.send(response);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /ai-imports/:id/apply
|
||||
* Applies approved/edited AI extracted categories & items directly into the restaurant's menu
|
||||
*/
|
||||
app.post("/ai-imports/:id/apply", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const parsed = applyAiImportSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "Geçersiz veri", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { menuId, categories } = parsed.data;
|
||||
|
||||
// Verify restaurant membership
|
||||
const { data: menuRow } = await supabase
|
||||
.from("menus")
|
||||
.select("locations!inner(restaurant_id)")
|
||||
.eq("id", menuId)
|
||||
.single();
|
||||
|
||||
if (!menuRow) return reply.code(404).send({ message: "Menü bulunamadı" });
|
||||
const restaurantId = (menuRow.locations as unknown as { restaurant_id: string }).restaurant_id;
|
||||
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "Yetkiniz yok" });
|
||||
|
||||
// Fetch existing category sort orders
|
||||
const { data: existingCategories } = await supabase
|
||||
.from("menu_categories")
|
||||
.select("id, name, sort_order")
|
||||
.eq("menu_id", menuId)
|
||||
.order("sort_order", { ascending: false });
|
||||
|
||||
let currentSortOrder = (existingCategories?.[0]?.sort_order ?? -1) + 1;
|
||||
let totalCategoriesInserted = 0;
|
||||
let totalItemsInserted = 0;
|
||||
|
||||
for (const cat of categories) {
|
||||
// Find or create category
|
||||
let categoryId: string;
|
||||
const existing = existingCategories?.find((c) => c.name.toLowerCase() === cat.name.toLowerCase());
|
||||
|
||||
if (existing) {
|
||||
categoryId = existing.id;
|
||||
} else {
|
||||
const { data: newCat, error: catError } = await supabase
|
||||
.from("menu_categories")
|
||||
.insert({
|
||||
menu_id: menuId,
|
||||
name: cat.name,
|
||||
sort_order: currentSortOrder++,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (catError || !newCat) {
|
||||
req.log.error(catError);
|
||||
continue;
|
||||
}
|
||||
categoryId = newCat.id;
|
||||
totalCategoriesInserted++;
|
||||
}
|
||||
|
||||
// Insert items under category
|
||||
const itemsToInsert = cat.items.map((itm, idx) => ({
|
||||
category_id: categoryId,
|
||||
name: itm.name,
|
||||
description: itm.description || null,
|
||||
price: itm.price,
|
||||
sort_order: idx,
|
||||
}));
|
||||
|
||||
if (itemsToInsert.length > 0) {
|
||||
const { error: itemsError } = await supabase.from("menu_items").insert(itemsToInsert);
|
||||
if (!itemsError) {
|
||||
totalItemsInserted += itemsToInsert.length;
|
||||
} else {
|
||||
req.log.error(itemsError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
categoriesCount: totalCategoriesInserted,
|
||||
itemsCount: totalItemsInserted,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
|
||||
export const analyticsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get("/analytics", async (_req, reply) => {
|
||||
return reply.code(501).send({ message: "not implemented" });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import dns from "node:dns/promises";
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||
import { env } from "../env.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const addDomainSchema = z.object({
|
||||
hostname: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(255)
|
||||
.transform((val) => val.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "")),
|
||||
});
|
||||
|
||||
const resolver = new dns.Resolver();
|
||||
// Use public authoritative DNS servers to check live global records
|
||||
try {
|
||||
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
||||
} catch {
|
||||
// Fallback to default
|
||||
}
|
||||
|
||||
export const domainsRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Get all domains for a restaurant
|
||||
app.get("/restaurants/:id/domains", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { data: domains, error } = await supabase
|
||||
.from("domains")
|
||||
.select()
|
||||
.eq("restaurant_id", restaurantId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to load domains" });
|
||||
}
|
||||
|
||||
const cnameTarget = `cname.${env.ROOT_DOMAIN}`;
|
||||
|
||||
return reply.send({
|
||||
domains: domains || [],
|
||||
cnameTarget,
|
||||
instructions: {
|
||||
type: "CNAME",
|
||||
target: cnameTarget,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Add a custom domain
|
||||
app.post("/restaurants/:id/domains", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const parsed = addDomainSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "Geçersiz alan adı formatı", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { hostname } = parsed.data;
|
||||
|
||||
// Check if domain is already registered
|
||||
const { data: existing } = await supabase
|
||||
.from("domains")
|
||||
.select("id, restaurant_id")
|
||||
.eq("hostname", hostname)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
if (existing.restaurant_id === restaurantId) {
|
||||
return reply.code(409).send({ message: "Bu alan adı zaten bu restorana eklenmiş." });
|
||||
}
|
||||
return reply.code(409).send({ message: "Bu alan adı başka bir hesap tarafından kullanılıyor." });
|
||||
}
|
||||
|
||||
const { data: newDomain, error } = await supabase
|
||||
.from("domains")
|
||||
.insert({
|
||||
restaurant_id: restaurantId,
|
||||
hostname,
|
||||
is_custom: true,
|
||||
status: "pending",
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !newDomain) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to add domain" });
|
||||
}
|
||||
|
||||
const cnameTarget = `cname.${env.ROOT_DOMAIN}`;
|
||||
|
||||
return reply.status(201).send({
|
||||
domain: newDomain,
|
||||
cnameTarget,
|
||||
instructions: {
|
||||
type: "CNAME",
|
||||
host: hostname,
|
||||
target: cnameTarget,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// REAL DNS Verification (Checks real CNAME records across 8.8.8.8 and 1.1.1.1)
|
||||
app.post("/restaurants/:id/domains/:domainId/verify", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId, domainId } = req.params as { id: string; domainId: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { data: domain, error: domainError } = await supabase
|
||||
.from("domains")
|
||||
.select()
|
||||
.eq("id", domainId)
|
||||
.eq("restaurant_id", restaurantId)
|
||||
.single();
|
||||
|
||||
if (domainError || !domain) {
|
||||
return reply.code(404).send({ message: "domain not found" });
|
||||
}
|
||||
|
||||
const expectedTarget = `cname.${env.ROOT_DOMAIN}`;
|
||||
let isVerified = false;
|
||||
let dnsErrorDetails = "";
|
||||
|
||||
try {
|
||||
// Real DNS lookup for CNAME
|
||||
const cnames = await resolver.resolveCname(domain.hostname);
|
||||
req.log.info({ hostname: domain.hostname, foundCnames: cnames }, "Real DNS CNAME check");
|
||||
|
||||
isVerified = cnames.some(
|
||||
(c) =>
|
||||
c.toLowerCase().includes(env.ROOT_DOMAIN.toLowerCase()) ||
|
||||
c.toLowerCase().includes(`cname.${env.ROOT_DOMAIN}`.toLowerCase()) ||
|
||||
c.toLowerCase().includes("menul.io"),
|
||||
);
|
||||
|
||||
if (!isVerified) {
|
||||
dnsErrorDetails = `Bulunan CNAME: ${cnames.join(", ")} (Beklenen: ${expectedTarget})`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
req.log.warn({ hostname: domain.hostname, err: err?.message || err }, "DNS CNAME lookup failed");
|
||||
if (err?.code === "ENOTFOUND" || err?.code === "ENODATA") {
|
||||
dnsErrorDetails = "DNS kaydı bulunamadı. Henüz CNAME yönlendirmesi yapılmamış veya DNS yayılımı (propagation) tamamlanmamış.";
|
||||
} else {
|
||||
dnsErrorDetails = err?.message || "DNS sorgusu başarısız oldu.";
|
||||
}
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
const { data: updated } = await supabase
|
||||
.from("domains")
|
||||
.update({
|
||||
status: "verified",
|
||||
verified_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", domainId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return reply.send({
|
||||
verified: true,
|
||||
domain: updated,
|
||||
message: `Tebrikler! ${domain.hostname} alan adının CNAME kaydı doğrulandı ve menünüze bağlandı. 🎉`,
|
||||
});
|
||||
}
|
||||
|
||||
// If not verified, set/keep status as pending or failed
|
||||
await supabase
|
||||
.from("domains")
|
||||
.update({
|
||||
status: "pending",
|
||||
verified_at: null,
|
||||
})
|
||||
.eq("id", domainId);
|
||||
|
||||
return reply.send({
|
||||
verified: false,
|
||||
domain: { ...domain, status: "pending", verified_at: null },
|
||||
message: `Doğrulanamadı: ${dnsErrorDetails}\n\nLütfen alan adı yönetim panelinizden (Cloudflare, GoDaddy, Natro vb.) ${domain.hostname} için CNAME kaydını ${expectedTarget} adresine yönlendirin.`,
|
||||
});
|
||||
});
|
||||
|
||||
// Delete custom domain
|
||||
app.delete("/restaurants/:id/domains/:domainId", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId, domainId } = req.params as { id: string; domainId: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { error } = await supabase
|
||||
.from("domains")
|
||||
.delete()
|
||||
.eq("id", domainId)
|
||||
.eq("restaurant_id", restaurantId);
|
||||
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to delete domain" });
|
||||
}
|
||||
|
||||
return reply.send({ success: true });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
// Mobile cold-start rehydration: "does this signed-in user already own a
|
||||
// restaurant?" — kept behind the API (not a direct Supabase read from
|
||||
// mobile) so ownership resolution stays in one place (PRD §23, §33).
|
||||
export const meRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get("/me/restaurant", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from("restaurant_members")
|
||||
.select("restaurant_id")
|
||||
.eq("user_id", userId)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (!membership) return reply.send({ restaurant: null });
|
||||
|
||||
const { data: restaurant } = await supabase
|
||||
.from("restaurants")
|
||||
.select("id, name, slug, locations(id, menus(id))")
|
||||
.eq("id", membership.restaurant_id)
|
||||
.single();
|
||||
|
||||
return reply.send({ restaurant });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { getCategoryIfMember, getMenuIfMember } from "../lib/access.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const createCategorySchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(500).nullish(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
const updateCategorySchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(500).nullish(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const menuCategoriesRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post("/menus/:menuId/categories", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { menuId } = req.params as { menuId: string };
|
||||
const access = await getMenuIfMember(userId, menuId);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const parsed = createCategorySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("menu_categories")
|
||||
.insert({
|
||||
menu_id: menuId,
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description ?? null,
|
||||
sort_order: parsed.data.sortOrder,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to create category" });
|
||||
}
|
||||
|
||||
return reply.code(201).send(data);
|
||||
});
|
||||
|
||||
app.patch("/menu-categories/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getCategoryIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const parsed = updateCategorySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { name, description, sortOrder, isActive } = parsed.data;
|
||||
const { data, error } = await supabase
|
||||
.from("menu_categories")
|
||||
.update({
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(sortOrder !== undefined ? { sort_order: sortOrder } : {}),
|
||||
...(isActive !== undefined ? { is_active: isActive } : {}),
|
||||
})
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to update category" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.delete("/menu-categories/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getCategoryIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const { error } = await supabase.from("menu_categories").delete().eq("id", id);
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to delete category" });
|
||||
}
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { getCategoryIfMember, getItemIfMember } from "../lib/access.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const createItemSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
description: z.string().max(1000).nullish(),
|
||||
price: z.number().nonnegative(),
|
||||
imageUrl: z.string().nullish(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
const updateItemSchema = z.object({
|
||||
name: z.string().min(1).max(160).optional(),
|
||||
description: z.string().max(1000).nullish(),
|
||||
price: z.number().nonnegative().optional(),
|
||||
imageUrl: z.string().nullish(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const menuItemsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post("/menu-categories/:categoryId/items", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { categoryId } = req.params as { categoryId: string };
|
||||
const access = await getCategoryIfMember(userId, categoryId);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const parsed = createItemSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("menu_items")
|
||||
.insert({
|
||||
category_id: categoryId,
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description ?? null,
|
||||
price: parsed.data.price,
|
||||
image_url: parsed.data.imageUrl ?? null,
|
||||
sort_order: parsed.data.sortOrder,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to create item" });
|
||||
}
|
||||
|
||||
return reply.code(201).send(data);
|
||||
});
|
||||
|
||||
app.patch("/menu-items/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getItemIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const parsed = updateItemSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { name, description, price, imageUrl, sortOrder, isActive } = parsed.data;
|
||||
const { data, error } = await supabase
|
||||
.from("menu_items")
|
||||
.update({
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(price !== undefined ? { price } : {}),
|
||||
...(imageUrl !== undefined ? { image_url: imageUrl } : {}),
|
||||
...(sortOrder !== undefined ? { sort_order: sortOrder } : {}),
|
||||
...(isActive !== undefined ? { is_active: isActive } : {}),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to update item" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.delete("/menu-items/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getItemIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const { error } = await supabase.from("menu_items").delete().eq("id", id);
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to delete item" });
|
||||
}
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { getMenuIfMember } from "../lib/access.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const updateMenuSchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
export const menusRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get("/menus/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getMenuIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const { data: categories, error } = await supabase
|
||||
.from("menu_categories")
|
||||
.select("*, menu_items(*)")
|
||||
.eq("menu_id", id)
|
||||
.order("sort_order", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to load menu" });
|
||||
}
|
||||
|
||||
return reply.send({ menu: access.menu, categories });
|
||||
});
|
||||
|
||||
app.patch("/menus/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getMenuIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const parsed = updateMenuSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("menus")
|
||||
.update({ name: parsed.data.name, updated_at: new Date().toISOString() })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to update menu" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.post("/menus/:id/publish", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getMenuIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("menus")
|
||||
.update({ is_published: true, published_at: new Date().toISOString() })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to publish menu" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.post("/menus/:id/unpublish", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const access = await getMenuIfMember(userId, id);
|
||||
if (!access) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("menus")
|
||||
.update({ is_published: false })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to unpublish menu" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
// PRD §7 — AI import lands here in Faz 2; V1 manual flow builds the same
|
||||
// categories/items shape by hand via the routes below.
|
||||
app.post("/menus/import", async (_req, reply) => {
|
||||
return reply.code(501).send({ message: "not implemented — Faz 2" });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||
import { env } from "../env.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
// QR image encodes a stable /q/:id redirect, never the restaurant's current
|
||||
// domain directly — so republishing the menu or moving to a custom domain
|
||||
// never invalidates a printed QR code (PRD §12).
|
||||
export const qrRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post("/restaurants/:id/qr", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id: restaurantId } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, restaurantId);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { data: restaurant, error: restaurantError } = await supabase
|
||||
.from("restaurants")
|
||||
.select("slug")
|
||||
.eq("id", restaurantId)
|
||||
.single();
|
||||
|
||||
if (restaurantError || !restaurant) {
|
||||
return reply.code(404).send({ message: "restaurant not found" });
|
||||
}
|
||||
|
||||
const webBaseUrl = env.PUBLIC_WEB_URL || "http://localhost:3000";
|
||||
const targetUrl = `${webBaseUrl}/menu/${restaurant.slug}`;
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from("qr_codes")
|
||||
.select("id")
|
||||
.eq("restaurant_id", restaurantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
await supabase
|
||||
.from("qr_codes")
|
||||
.update({ target_url: targetUrl })
|
||||
.eq("id", existing.id);
|
||||
} else {
|
||||
await supabase
|
||||
.from("qr_codes")
|
||||
.insert({ restaurant_id: restaurantId, target_url: targetUrl });
|
||||
}
|
||||
|
||||
// QR image directly encodes the clean restaurant name URL (e.g. /menu/kebapci-ahmet)
|
||||
const [pngBuffer, svg] = await Promise.all([
|
||||
QRCode.toBuffer(targetUrl, { type: "png", width: 1024, margin: 2 }),
|
||||
QRCode.toString(targetUrl, { type: "svg", margin: 2 }),
|
||||
]);
|
||||
|
||||
return reply.send({
|
||||
id: restaurant.slug,
|
||||
redirectUrl: targetUrl,
|
||||
targetUrl,
|
||||
pngBase64: pngBuffer.toString("base64"),
|
||||
svg,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||
import { generateUniqueRestaurantSlug } from "../lib/slug.js";
|
||||
import { supabase } from "../lib/supabase.js";
|
||||
|
||||
const createRestaurantSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
logoUrl: z.string().nullish(),
|
||||
phone: z.string().max(30).nullish(),
|
||||
address: z.string().max(500).nullish(),
|
||||
});
|
||||
|
||||
const updateRestaurantSchema = createRestaurantSchema.partial();
|
||||
|
||||
export const restaurantsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post("/restaurants", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const parsed = createRestaurantSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { name, logoUrl, phone, address } = parsed.data;
|
||||
const slug = await generateUniqueRestaurantSlug(name);
|
||||
|
||||
const { data: restaurant, error: restaurantError } = await supabase
|
||||
.from("restaurants")
|
||||
.insert({
|
||||
name,
|
||||
slug,
|
||||
logo_url: logoUrl ?? null,
|
||||
phone: phone ?? null,
|
||||
address: address ?? null,
|
||||
created_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (restaurantError || !restaurant) {
|
||||
req.log.error(restaurantError);
|
||||
return reply.code(500).send({ message: "failed to create restaurant" });
|
||||
}
|
||||
|
||||
const { error: memberError } = await supabase
|
||||
.from("restaurant_members")
|
||||
.insert({ restaurant_id: restaurant.id, user_id: userId, role: "owner" });
|
||||
|
||||
const { data: location, error: locationError } = await supabase
|
||||
.from("locations")
|
||||
.insert({ restaurant_id: restaurant.id, name: "Ana Şube" })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (memberError || locationError || !location) {
|
||||
req.log.error(memberError ?? locationError);
|
||||
return reply.code(500).send({ message: "failed to finish restaurant setup" });
|
||||
}
|
||||
|
||||
const { data: menu, error: menuError } = await supabase
|
||||
.from("menus")
|
||||
.insert({ location_id: location.id, name: "Menüm" })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (menuError || !menu) {
|
||||
req.log.error(menuError);
|
||||
return reply.code(500).send({ message: "failed to create default menu" });
|
||||
}
|
||||
|
||||
return reply.code(201).send({ restaurant, location, menu });
|
||||
});
|
||||
|
||||
app.get("/restaurants/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, id);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { data, error } = await supabase.from("restaurants").select().eq("id", id).single();
|
||||
if (error || !data) return reply.code(404).send({ message: "not found" });
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.patch("/restaurants/:id", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, id);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const parsed = updateRestaurantSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "invalid body", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { name, logoUrl, phone, address } = parsed.data;
|
||||
const { data, error } = await supabase
|
||||
.from("restaurants")
|
||||
.update({
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(logoUrl !== undefined ? { logo_url: logoUrl } : {}),
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(address !== undefined ? { address } : {}),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to update restaurant" });
|
||||
}
|
||||
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
app.get("/restaurants/:id/theme", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, id);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { data: themeRow } = await supabase
|
||||
.from("restaurant_themes")
|
||||
.select("theme_id, overrides, themes(key, name, config)")
|
||||
.eq("restaurant_id", id)
|
||||
.maybeSingle();
|
||||
|
||||
return reply.send({
|
||||
themeKey: (themeRow?.themes as unknown as { key?: string } | null)?.key ?? "elegant",
|
||||
overrides: themeRow?.overrides ?? {},
|
||||
});
|
||||
});
|
||||
|
||||
app.put("/restaurants/:id/theme", async (req, reply) => {
|
||||
const userId = await requireAuth(req, reply);
|
||||
if (!userId || !supabase) return;
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const isMember = await requireRestaurantMember(userId, id);
|
||||
if (!isMember) return reply.code(403).send({ message: "forbidden" });
|
||||
|
||||
const { themeKey } = (req.body as { themeKey?: string }) ?? {};
|
||||
if (!themeKey) {
|
||||
return reply.code(400).send({ message: "themeKey is required" });
|
||||
}
|
||||
|
||||
const { data: themeData } = await supabase
|
||||
.from("themes")
|
||||
.select("id")
|
||||
.eq("key", themeKey)
|
||||
.maybeSingle();
|
||||
|
||||
if (!themeData) {
|
||||
return reply.code(404).send({ message: "Theme not found" });
|
||||
}
|
||||
|
||||
const { error } = await supabase.from("restaurant_themes").upsert(
|
||||
{
|
||||
restaurant_id: id,
|
||||
theme_id: themeData.id,
|
||||
},
|
||||
{ onConflict: "restaurant_id" },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
req.log.error(error);
|
||||
return reply.code(500).send({ message: "failed to update theme" });
|
||||
}
|
||||
|
||||
return reply.send({ success: true, themeKey });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
|
||||
export const subscriptionRoutes: FastifyPluginAsync = async (app) => {
|
||||
// RevenueCat webhook — backend is the source of truth for entitlement status,
|
||||
// mobile app local state is never trusted (see PRD §16).
|
||||
app.post("/subscription/webhook", async (_req, reply) => {
|
||||
return reply.code(501).send({ message: "not implemented" });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user