first commit

This commit is contained in:
AyrisAI
2026-08-20 01:51:59 +03:00
commit 97b83c7fd4
109 changed files with 21215 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
PORT=3001
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=
REVENUECAT_WEBHOOK_SECRET=
ROOT_DOMAIN=menulio.app
+40
View File
@@ -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"]
+28
View File
@@ -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"
}
}
+22
View File
@@ -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);
+52
View File
@@ -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);
});
+49
View File
@@ -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 };
}
+240
View File
@@ -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);
}
+49
View File
@@ -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;
}
+21
View File
@@ -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()}`;
}
+7
View File
@@ -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;
+316
View File
@@ -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,
});
});
};
+7
View File
@@ -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" });
});
};
+220
View File
@@ -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 });
});
};
+30
View File
@@ -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 });
});
};
+103
View File
@@ -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();
});
};
+112
View File
@@ -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();
});
};
+111
View File
@@ -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" });
});
};
+63
View File
@@ -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,
});
});
};
+183
View File
@@ -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 });
});
};
+9
View File
@@ -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" });
});
};
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": ["src"]
}