Files
menulio/apps/api/src/routes/domains.ts
T

263 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ReturnType<typeof getCustomHostnameByHostname>> = 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 });
});
};