feat(leads): submit contact requests to native platform intake

This commit is contained in:
Raffael Sheikh committed 2026-07-16 12:33:08 +02:00
1 parent 41664c3d0d
commit 079764d93d
12 files changed
+760 -154

No files matched your search

+244 -126
View File
@@ -22,9 +22,6 @@ const PUBLIC_DIR = path.join(ROOT, "public");
const DATA_DIR = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR)
: ROOT;
const LEADS_DIR = path.join(DATA_DIR, "leads");
const LEADS_JSON = path.join(LEADS_DIR, "leads.ndjson");
const LEADS_CSV = path.join(LEADS_DIR, "leads.csv");
/* GameJam-Speicher */
const GAMEJAM_DIR = path.join(DATA_DIR, "gamejam");
@@ -38,31 +35,127 @@ const MAX_UPLOAD = 5 * 1024 * 1024; /* 5 MB pro eingereichtem Spiel */
const UPLOAD_DEADLINE = new Date("2026-05-27T22:00:00+02:00");
const APP_REVISION = process.env.APP_REVISION || "local";
/* Central lead-inbox (infra ADR 012). Sites forward leads here; local files
stay as a fallback. Unset env => old local-only behavior. */
const LEAD_INBOX_URL = process.env.LEAD_INBOX_URL || "";
const LEAD_INBOX_KEY = process.env.LEAD_INBOX_KEY || "";
const PLATFORM_LEADS_PATH = "/v1/platform/leads";
const PLATFORM_LEAD_RETRYABLE_STATUSES = new Set([502, 503, 504]);
const PLATFORM_LEAD_CREDENTIAL_PATTERN = /^[A-Za-z0-9._~-]{32,512}$/;
const SUBMISSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,199}$/;
const TOOLCTRL_SOURCE_DETAIL = "Messe — Landing Page";
const TOOLCTRL_UTM_CAMPAIGN = "messe-landing-page";
async function forwardLeadToInbox(lead) {
if (!LEAD_INBOX_URL || !LEAD_INBOX_KEY) return false;
try {
const res = await fetch(LEAD_INBOX_URL.replace(/\/+$/, "") + "/v1/leads", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + LEAD_INBOX_KEY
},
/* inbox accepts DE aliases: firma, telefon, nachricht, quelle */
body: JSON.stringify(lead),
signal: AbortSignal.timeout(5000)
});
return res.ok;
} catch (e) {
console.error("[lead] inbox forward failed:", e.message);
return false;
class PlatformLeadConfigurationError extends Error {
constructor() {
super("Platform lead producer is not configured");
this.name = "PlatformLeadConfigurationError";
}
}
class PlatformLeadSubmissionError extends Error {
constructor(code, upstreamStatus) {
super("Platform lead submission failed: " + code);
this.name = "PlatformLeadSubmissionError";
this.code = code;
this.upstreamStatus = upstreamStatus;
}
}
function platformLeadConfig(env) {
const rawUrl = String(env.PLATFORM_LEADS_API_URL || "").trim();
const key = String(env.PLATFORM_LEADS_API_KEY || "").trim();
if (!rawUrl || !PLATFORM_LEAD_CREDENTIAL_PATTERN.test(key)) {
throw new PlatformLeadConfigurationError();
}
let baseUrl;
try {
baseUrl = new URL(rawUrl);
} catch (_error) {
throw new PlatformLeadConfigurationError();
}
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(baseUrl.hostname);
const validProtocol = baseUrl.protocol === "https:" ||
(baseUrl.protocol === "http:" && loopback);
if (!validProtocol || (baseUrl.pathname !== "/" && baseUrl.pathname !== "") ||
baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) {
throw new PlatformLeadConfigurationError();
}
return {
endpoint: new URL(PLATFORM_LEADS_PATH, baseUrl).toString(),
key: key
};
}
function compactObject(value) {
return Object.fromEntries(
Object.entries(value).filter((entry) => entry[1] !== undefined)
);
}
async function forwardLeadToPlatform(lead, options) {
const opts = options || {};
const config = platformLeadConfig(opts.env || process.env);
const fetchImpl = opts.fetchImpl || fetch;
const maxAttempts = opts.maxAttempts || 2;
const timeoutMs = opts.timeoutMs || 5000;
const payload = compactObject({
externalId: lead.submissionId,
sourceDetail: TOOLCTRL_SOURCE_DETAIL,
firstName: lead.firstName,
lastName: lead.lastName,
email: lead.email,
phone: lead.phone,
company: lead.company,
message: lead.message,
utmCampaign: TOOLCTRL_UTM_CAMPAIGN,
metadata: {
consent: true,
formId: "contact-form",
formName: "ToolCTRL Kontaktformular",
locale: "de-DE",
submissionId: lead.submissionId
}
});
const serializedPayload = JSON.stringify(payload);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
let response;
try {
response = await fetchImpl(config.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + config.key,
"Idempotency-Key": lead.submissionId
},
body: serializedPayload,
signal: AbortSignal.timeout(timeoutMs)
});
} catch (_error) {
if (attempt < maxAttempts) continue;
throw new PlatformLeadSubmissionError("network");
}
if (response.status === 201) {
const result = await response.json().catch(() => null);
if (result && typeof result.id === "string" &&
typeof result.status === "string" &&
typeof result.createdAt === "string") {
return result;
}
throw new PlatformLeadSubmissionError("invalid_response", response.status);
}
if (PLATFORM_LEAD_RETRYABLE_STATUSES.has(response.status) &&
attempt < maxAttempts) {
continue;
}
throw new PlatformLeadSubmissionError("upstream", response.status);
}
throw new PlatformLeadSubmissionError("network");
}
function envFlag(name, defaultValue) {
const raw = process.env[name];
if (raw == null || raw === "") return defaultValue;
@@ -120,18 +213,6 @@ const MIME = {
".xml": "application/xml; charset=utf-8"
};
/* ---------- Lead-Speicher vorbereiten ---------- */
function ensureLeadStore() {
if (!fs.existsSync(LEADS_DIR)) fs.mkdirSync(LEADS_DIR, { recursive: true });
if (!fs.existsSync(LEADS_CSV)) {
fs.writeFileSync(
LEADS_CSV,
"zeitpunkt,name,firma,email,telefon,nachricht,quelle\n",
"utf8"
);
}
}
/* ---------- GameJam-Speicher vorbereiten ---------- */
function ensureGamejamStore() {
if (!fs.existsSync(GAMEJAM_DIR)) fs.mkdirSync(GAMEJAM_DIR, { recursive: true });
@@ -150,28 +231,6 @@ function writeProbe(dir) {
fs.unlinkSync(file);
}
function csvCell(value) {
const s = String(value == null ? "" : value);
return '"' + s.replace(/"/g, '""').replace(/\r?\n/g, " ") + '"';
}
function saveLead(lead) {
ensureLeadStore();
fs.appendFileSync(LEADS_JSON, JSON.stringify(lead) + "\n", "utf8");
const row = [
lead.zeitpunkt,
lead.name,
lead.firma,
lead.email,
lead.telefon,
lead.nachricht,
lead.quelle
]
.map(csvCell)
.join(",");
fs.appendFileSync(LEADS_CSV, row + "\n", "utf8");
}
/* ---------- Statische Dateien ---------- */
function serveStatic(req, res) {
let urlPath = decodeURIComponent(req.url.split("?")[0]);
@@ -228,19 +287,26 @@ function serveStatic(req, res) {
}
/* ---------- Lead-Endpunkt ---------- */
function handleLead(req, res) {
function handleLead(req, res, submitLead) {
const contentLength = Number.parseInt(req.headers["content-length"] || "0", 10);
if (Number.isFinite(contentLength) && contentLength > 64 * 1024) {
req.resume();
return sendJSON(res, 413, { error: "Anfrage zu groß." });
}
let body = "";
let tooLarge = false;
req.on("data", (chunk) => {
if (tooLarge) return;
body += chunk;
if (body.length > 64 * 1024) {
if (Buffer.byteLength(body, "utf8") > 64 * 1024) {
tooLarge = true;
req.destroy();
body = "";
}
});
req.on("end", () => {
req.on("end", async () => {
if (tooLarge) return sendJSON(res, 413, { error: "Anfrage zu groß." });
let data;
@@ -250,16 +316,29 @@ function handleLead(req, res) {
return sendJSON(res, 400, { error: "Ungültiges Format." });
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
return sendJSON(res, 400, { error: "Ungültiges Format." });
}
/* Honeypot — befülltes Feld = Bot. Still mit OK quittieren. */
if (data.website && String(data.website).trim() !== "") {
return sendJSON(res, 200, { ok: true });
}
const name = String(data.name || "").trim();
const email = String(data.email || "").trim();
const nachricht = String(data.nachricht || "").trim();
const stringFields = ["name", "email", "nachricht", "firma", "telefon", "submissionId"];
if (stringFields.some((field) =>
data[field] != null && typeof data[field] !== "string")) {
return sendJSON(res, 400, { error: "Ungültiges Format." });
}
if (!name || !email || !nachricht) {
const name = String(data.name || "").trim();
const email = String(data.email || "").trim().toLowerCase();
const message = String(data.nachricht || "").trim();
const company = String(data.firma || "").trim();
const phone = String(data.telefon || "").trim();
const submissionId = String(data.submissionId || "").trim();
if (!name || !email || !message) {
return sendJSON(res, 400, {
error: "Bitte Name, E-Mail und Nachricht angeben."
});
@@ -267,38 +346,55 @@ function handleLead(req, res) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return sendJSON(res, 400, { error: "Bitte eine gültige E-Mail angeben." });
}
if (!SUBMISSION_ID_PATTERN.test(submissionId)) {
return sendJSON(res, 400, { error: "Gültige Submission-ID erforderlich." });
}
if (name.length > 241 || email.length > 320 || company.length > 200 ||
phone.length > 80 || message.length > 4000) {
return sendJSON(res, 400, { error: "Eingabe ist zu lang." });
}
const nameParts = name.split(/\s+/);
const firstName = nameParts.shift();
const lastName = nameParts.join(" ") || undefined;
if (firstName.length > 120 || (lastName && lastName.length > 120)) {
return sendJSON(res, 400, { error: "Name ist zu lang." });
}
const lead = {
zeitpunkt: new Date().toISOString(),
name: name.slice(0, 200),
firma: String(data.firma || "").trim().slice(0, 200),
email: email.slice(0, 200),
telefon: String(data.telefon || "").trim().slice(0, 80),
nachricht: nachricht.slice(0, 4000),
quelle: String(data.quelle || "Landing Page").slice(0, 120)
submissionId: submissionId,
firstName: firstName,
lastName: lastName,
email: email,
phone: phone || undefined,
company: company || undefined,
message: message
};
/* Central inbox is source of truth; local files are the fallback so a
lead is never lost if the inbox is unreachable (infra ADR 012). */
forwardLeadToInbox(lead).then((forwarded) => {
if (!forwarded) {
try {
saveLead(lead);
} catch (e) {
console.error("[lead] Speichern fehlgeschlagen:", e.message);
return sendJSON(res, 500, {
error: "Speichern fehlgeschlagen. Bitte später erneut versuchen."
});
}
try {
const result = await submitLead(lead);
console.log("[lead] →platform submission=" + submissionId + " lead=" + result.id);
return sendJSON(res, 200, { ok: true, id: result.id });
} catch (error) {
if (error instanceof PlatformLeadConfigurationError) {
console.error("[lead] platform producer is not configured");
return sendJSON(res, 503, {
error: "Anfragen können derzeit nicht übermittelt werden. Bitte später erneut versuchen."
});
}
console.log(
"[lead] " + (forwarded ? "→inbox " : "→local ") +
lead.zeitpunkt + " — " + lead.name +
(lead.firma ? " (" + lead.firma + ")" : "") + " <" + lead.email + ">"
);
sendJSON(res, 200, { ok: true });
});
if (error instanceof PlatformLeadSubmissionError) {
console.error("[lead] platform submission failed", {
code: error.code,
upstreamStatus: error.upstreamStatus
});
return sendJSON(res, 502, {
error: "Anfrage konnte nicht übermittelt werden. Bitte erneut versuchen."
});
}
console.error("[lead] unexpected platform producer failure");
return sendJSON(res, 500, {
error: "Interner Fehler. Bitte später erneut versuchen."
});
}
});
}
@@ -560,15 +656,20 @@ async function handleReadyz(req, res) {
}
const checks = {
leadsWritable: false,
platformLeadsConfigured: false,
gamejamWritable: false,
gamesWritable: false,
clamav: !CLAMAV.enabled
};
try {
writeProbe(LEADS_DIR);
checks.leadsWritable = true;
platformLeadConfig(process.env);
checks.platformLeadsConfigured = true;
} catch (_error) {
checks.platformLeadsConfigured = false;
}
try {
writeProbe(GAMEJAM_DIR);
checks.gamejamWritable = true;
writeProbe(GAMES_DIR);
@@ -588,42 +689,45 @@ async function handleReadyz(req, res) {
}
/* ---------- Router ---------- */
const server = http.createServer((req, res) => {
const route = req.url.split("?")[0];
function createServer(options) {
const submitLead = (options && options.submitLead) || forwardLeadToPlatform;
return http.createServer((req, res) => {
const route = req.url.split("?")[0];
if (route === "/healthz") return handleHealthz(req, res);
if (route === "/readyz") return handleReadyz(req, res);
if (route === "/healthz") return handleHealthz(req, res);
if (route === "/readyz") return handleReadyz(req, res);
if (route === "/api/lead") {
if (req.method !== "POST") {
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
if (route === "/api/lead") {
if (req.method !== "POST") {
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
}
return handleLead(req, res, submitLead);
}
return handleLead(req, res);
}
if (route === "/api/gamejam/upload") {
if (req.method !== "POST") {
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
if (route === "/api/gamejam/upload") {
if (req.method !== "POST") {
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
}
return handleGamejamUpload(req, res);
}
return handleGamejamUpload(req, res);
}
if (route === "/api/gamejam/games") {
if (req.method !== "GET") {
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
if (route === "/api/gamejam/games") {
if (req.method !== "GET") {
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
}
return handleGamejamGames(req, res);
}
return handleGamejamGames(req, res);
}
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405).end("Method Not Allowed");
return;
}
serveStatic(req, res);
});
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405).end("Method Not Allowed");
return;
}
serveStatic(req, res);
});
}
/* ---------- Freien Port suchen und starten ---------- */
function listen(candidates) {
function listen(server, candidates) {
if (candidates.length === 0) {
console.error("Kein freier Port gefunden.");
process.exit(1);
@@ -632,7 +736,7 @@ function listen(candidates) {
server.once("error", (err) => {
if (err.code === "EADDRINUSE") {
console.log("Port " + port + " belegt — versuche nächsten …");
listen(candidates.slice(1));
listen(server, candidates.slice(1));
} else {
console.error("Serverfehler:", err.message);
process.exit(1);
@@ -640,7 +744,6 @@ function listen(candidates) {
});
server.listen(port, HOST, () => {
const actual = server.address().port;
ensureLeadStore();
ensureGamejamStore();
const lan = getLanIP();
console.log("");
@@ -651,7 +754,12 @@ function listen(candidates) {
console.log(" Netzwerk: http://" + lan + ":" + actual +
" (andere Geräte im selben WLAN / QR-Code)");
}
console.log(" Leads: " + LEADS_CSV);
try {
platformLeadConfig(process.env);
console.log(" Plattform-Leads: konfiguriert");
} catch (_error) {
console.log(" Plattform-Leads: NICHT konfiguriert — Formulare liefern 503");
}
console.log(" GameJam-Einreichungen: " + SUBMISSIONS_JSON);
console.log(" Daten: " + DATA_DIR);
console.log(" Revision: " + APP_REVISION);
@@ -669,4 +777,14 @@ function listen(candidates) {
});
}
listen(PORT_CANDIDATES);
if (require.main === module) {
listen(createServer(), PORT_CANDIDATES);
}
module.exports = {
PlatformLeadConfigurationError,
PlatformLeadSubmissionError,
createServer,
forwardLeadToPlatform,
platformLeadConfig
};