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"; import { addDomainToWebApp } from "../lib/coolify.js"; import { createCustomHostname, getCustomHostnameByHostname } from "../lib/cloudflare.js"; const addDomainSchema = z.object({ hostname: z .string() .min(3) .max(255) .transform((val) => val.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "")), }); 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}`; const enrichedDomains = await Promise.all( (domains || []).map(async (domain) => { if (domain.status === "verified") return domain; try { const cfHostname = await getCustomHostnameByHostname(domain.hostname); if (cfHostname) { const ownershipTxt = cfHostname.ownership_verification ? { name: cfHostname.ownership_verification.name, value: cfHostname.ownership_verification.value } : null; const sslTxt = (cfHostname.ssl?.validation_records ?? []).map((r) => ({ name: r.txt_name, value: r.txt_value, })); return { ...domain, instructions: { cname: { type: "CNAME", host: domain.hostname, target: cnameTarget }, ownershipTxt, sslTxt, }, cloudflareStatus: { hostnameStatus: cfHostname.status, sslStatus: cfHostname.ssl?.status, ownershipTxt, sslTxt, }, }; } } catch (err) { req.log.warn({ err, hostname: domain.hostname }, "Cloudflare check failed during GET domains"); } return domain; }), ); return reply.send({ domains: enrichedDomains, cnameTarget, instructions: { type: "CNAME", target: cnameTarget, }, }); }); // Add a custom domain — registers it with Cloudflare (SSL for SaaS) and // the origin (Coolify) so it's routable/certifiable, not just recorded. 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; 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}`; const warnings: string[] = []; let dcvTxt: { name: string; value: string } | null = null; let sslTxt: { name: string; value: string }[] = []; try { const hostnameRecord = await createCustomHostname(hostname); if (hostnameRecord.ownership_verification) { dcvTxt = { name: hostnameRecord.ownership_verification.name, value: hostnameRecord.ownership_verification.value }; } sslTxt = (hostnameRecord.ssl.validation_records ?? []).map((r) => ({ name: r.txt_name, value: r.txt_value })); } catch (err) { req.log.warn({ err, hostname }, "Cloudflare custom hostname creation failed"); warnings.push("Cloudflare kaydı oluşturulamadı, manuel kurulum gerekebilir."); } try { await addDomainToWebApp(hostname); } catch (err) { req.log.warn({ err, hostname }, "Coolify domain add failed"); warnings.push("Sunucu routing'i otomatik eklenemedi, manuel kurulum gerekebilir."); } return reply.status(201).send({ domain: newDomain, cnameTarget, instructions: { cname: { type: "CNAME", host: hostname, target: cnameTarget }, ownershipTxt: dcvTxt, sslTxt, }, warnings, }); }); // Verification checks Cloudflare's own Custom Hostname SSL status — a // plain DNS lookup can't see a proxied domain's real CNAME target, so // that approach always failed for hostnames set up correctly (see // docs/PROGRESS.md, 2026-08-20). 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" }); } let cfHostname: Awaited> = null; try { cfHostname = await getCustomHostnameByHostname(domain.hostname); } catch (err) { req.log.warn({ err, hostname: domain.hostname }, "Cloudflare status check failed"); } const isVerified = cfHostname?.status === "active" && cfHostname?.ssl?.status === "active"; 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ı doğrulandı ve menünüze bağlandı. 🎉`, }); } // Never downgrade an already-verified domain on an inconclusive re-check. if (domain.status !== "verified") { await supabase.from("domains").update({ status: "pending", verified_at: null }).eq("id", domainId); } return reply.send({ verified: domain.status === "verified", domain: domain.status === "verified" ? domain : { ...domain, status: "pending", verified_at: null }, cloudflareStatus: cfHostname ? { hostnameStatus: cfHostname.status, sslStatus: cfHostname.ssl?.status, ownershipTxt: cfHostname.ownership_verification ? { name: cfHostname.ownership_verification.name, value: cfHostname.ownership_verification.value } : null, sslTxt: (cfHostname.ssl?.validation_records ?? []).map((r) => ({ name: r.txt_name, value: r.txt_value })), } : null, message: cfHostname ? `Henüz aktif değil (hostname: ${cfHostname.status}, sertifika: ${cfHostname.ssl?.status}). DNS kayıtlarının işlenmesi birkaç dakika sürebilir.` : `${domain.hostname} Cloudflare'de bulunamadı. Domain eklerken bir hata oluşmuş olabilir, tekrar eklemeyi deneyin.`, }); }); // 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 }); }); };