feat(leads): submit contact requests to native platform intake
This commit is contained in:
1 parent
41664c3d0d
commit
079764d93d
12 files changed
+760
-154
No files matched your search
@@ -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
|
||||
};
|
||||
Reference in new issue
Block a user