Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The browser pixel under-counts signups badly: ~96% of paid traffic is mobile in-app browsers where iOS ITP / ad-blockers drop client events, and the OAuth path never fired it reliably. Meta recorded ~0 registrations for a 7.5K-spend campaign while PostHog saw 98 facebook signups — so Meta could neither optimize toward nor attribute signups, which is the main driver of the low signup rate. This adds a server-side CAPI CompleteRegistration: - MetaCapiService + @Global module. Fail-open: no-ops unless META_CAPI_PIXEL_ID + META_CAPI_ACCESS_TOKEN are set; never throws (signup must not break). SHA-256 hashed email + fbp/fbc/IP/UA. - Fired from the better-auth user.create.after hook for ALL signups (reliable, covers Google OAuth which the browser pixel missed entirely). - A session-gated POST /analytics/meta/complete-registration endpoint adds fbp/fbc/IP/UA (ad-click attribution) for the email path. - The browser pixel now passes a shared event_id (signup_<userId>); the premature Google client-pixel fire (fired on click, before completion) is removed. - All sources dedupe via event_id=signup_<userId>. Activate by setting META_CAPI_PIXEL_ID + META_CAPI_ACCESS_TOKEN (Events Manager) in the api env; META_CAPI_TEST_EVENT_CODE routes to Test Events for verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
8.4 KiB
TypeScript
195 lines
8.4 KiB
TypeScript
import { z } from "zod";
|
||
|
||
export const envSchema = z.object({
|
||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||
PORT: z.coerce.number().default(4000),
|
||
|
||
DATABASE_URL: z.string().url(),
|
||
|
||
REDIS_HOST: z.string().default("127.0.0.1"),
|
||
REDIS_PORT: z.coerce.number().default(6379),
|
||
REDIS_PASSWORD: z.string(),
|
||
|
||
BETTER_AUTH_SECRET: z.string().min(32),
|
||
BETTER_AUTH_URL: z.string().url(),
|
||
|
||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||
|
||
// Cloudflare Turnstile (captcha) — tanımlı değilse captcha atlanır
|
||
TURNSTILE_SECRET_KEY: z.string().optional(),
|
||
|
||
MINIO_ENDPOINT: z.string(),
|
||
MINIO_ACCESS_KEY: z.string(),
|
||
MINIO_SECRET_KEY: z.string(),
|
||
MINIO_BUCKET_NAME: z.string().default("sase-schemas"),
|
||
MINIO_PUBLIC_URL: z.string(),
|
||
MINIO_USE_SSL: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("false"),
|
||
|
||
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||
|
||
STRIPE_SECRET_KEY: z.string().optional(),
|
||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||
STRIPE_SUCCESS_URL: z
|
||
.string()
|
||
.url()
|
||
.default("http://localhost:3000/dashboard/subscription?stripe=success"),
|
||
STRIPE_CANCEL_URL: z
|
||
.string()
|
||
.url()
|
||
.default("http://localhost:3000/dashboard/subscription?stripe=cancelled"),
|
||
|
||
PL24_BASE_URL: z.string().optional(),
|
||
PL24_COMPANY_CODE: z.string().optional(),
|
||
PL24_USERNAME: z.string().optional(),
|
||
PL24_PASSWORD: z.string().optional(),
|
||
PL24_COMPANY_CODE_2: z.string().optional(), // de-708171
|
||
PL24_USERNAME_2: z.string().optional(),
|
||
PL24_PASSWORD_2: z.string().optional(),
|
||
PL24_PROXY_DE: z.string().optional(), // http://user:pass@gw.dataimpulse.com:10000
|
||
|
||
EMEX_USERNAME: z.string().optional(),
|
||
EMEX_PASSWORD: z.string().optional(),
|
||
|
||
// Parts-Catalogs (Playwright JWT capture + DataImpulse proxy)
|
||
PCAT_USE_PROXY: z.string().default("true"),
|
||
PCAT_PROXY_HOST: z.string().default("gw.dataimpulse.com"),
|
||
PCAT_PROXY_USER: z.string().optional(),
|
||
PCAT_PROXY_PASS: z.string().optional(),
|
||
|
||
ML_PREDICTION_ENABLED: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("false"),
|
||
|
||
// OpenRouter (used by scripts/emex-translate-bootstrap.ts → DeepSeek V3)
|
||
OPENROUTER_API_KEY: z.string().optional(),
|
||
|
||
// Postal Email
|
||
POSTAL_API_URL: z.string().url().optional(),
|
||
POSTAL_API_KEY: z.string().optional(),
|
||
POSTAL_FROM_ADDRESS: z.string().email().default("noreply@sase.tr"),
|
||
POSTAL_FROM_NAME: z.string().default("Sase.tr"),
|
||
|
||
// Novu — lifecycle/transactional email automation (Tailscale-only, sends via Postal).
|
||
// When NOVU_API_KEY is unset, triggers are logged and skipped (dev fallback).
|
||
NOVU_API_URL: z.string().url().default("https://api.bildirim.semih.ai"),
|
||
NOVU_API_KEY: z.string().optional(),
|
||
// Public marketing-site origin used to build CTA targets (e.g. https://sase.tr/dashboard).
|
||
APP_PUBLIC_URL: z.string().url().default("https://sase.tr"),
|
||
// HMAC secret for signed track.sase.tr click links. When unset, CTAs are passed
|
||
// un-wrapped (no click tracking) — links still work.
|
||
MAILTRACK_SECRET: z.string().optional(),
|
||
|
||
// OpenTelemetry
|
||
OTEL_ENABLED: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("false"),
|
||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
|
||
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
|
||
OTEL_SERVICE_NAME: z.string().default("sase-api"),
|
||
OTEL_TRACE_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0),
|
||
|
||
// PostHog — product analytics (server-side)
|
||
POSTHOG_API_KEY: z.string().optional(),
|
||
POSTHOG_HOST: z.string().url().default("https://t.sase.tr"),
|
||
// Personal API key (or feature-flags secure key) → enables LOCAL server-side
|
||
// feature-flag evaluation: source kill switches, guarded decode rollout, and
|
||
// remote-config ops tuning. Without it those flags fail open (no effect, no
|
||
// added latency). Keep secret — never expose to the browser.
|
||
POSTHOG_PERSONAL_API_KEY: z.string().optional(),
|
||
// Gate server-side analytics CAPTURE independently of flag evaluation, so a
|
||
// non-prod env can evaluate flags (kill switches, rollout) while the project
|
||
// key is set WITHOUT shipping events to the shared prod project. Default on;
|
||
// set "false" on dev. (Plain string — read as `!== "false"`.)
|
||
POSTHOG_CAPTURE_ENABLED: z.string().optional(),
|
||
|
||
// Meta Conversions API — server-side signup tracking (CompleteRegistration) so
|
||
// Meta can optimize toward / attribute signups despite the unreliable browser
|
||
// pixel. Empty → CAPI no-ops. Pixel id is public; the access token is a secret
|
||
// from Events Manager. Test event code routes to Events Manager > Test Events.
|
||
META_CAPI_PIXEL_ID: z.string().optional(),
|
||
META_CAPI_ACCESS_TOKEN: z.string().optional(),
|
||
META_CAPI_TEST_EVENT_CODE: z.string().optional(),
|
||
|
||
// Sentry — error tracking
|
||
SENTRY_DSN: z.string().url().optional(),
|
||
|
||
// Changelog automation (token-authed internal endpoint for Fusion webhook)
|
||
CHANGELOG_AUTOMATION_TOKEN: z.string().min(32).optional(),
|
||
|
||
// Chatwoot — HMAC secret for the verified live-chat widget identity
|
||
// (/api/chatwoot/identity → setUser identifier_hash). When unset, that
|
||
// endpoint returns 503 and the widget falls back to anonymous visitors.
|
||
CHATWOOT_HMAC_TOKEN: z.string().optional(),
|
||
|
||
// Catalog-source dumps — local DB-first lookup before live scrape.
|
||
// When CATALOG_SOURCE_DB_ENABLED is "true" AND the URL for a source is set,
|
||
// prefetch/category fetches will try the local dump DB first and only fall
|
||
// back to the live upstream on a miss. Both URLs optional independently.
|
||
CATALOG_SOURCE_DB_ENABLED: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("false"),
|
||
// docker-compose's `${VAR:-}` substitution always sets the env, even if to
|
||
// an empty string. zod's `.optional()` only accepts undefined, so a chained
|
||
// `.url()` would reject "" and crash boot — preprocess "" → undefined first.
|
||
PCAT_SOURCE_DB_URL: z.preprocess(
|
||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||
z.string().url().optional(),
|
||
),
|
||
EMEX_SOURCE_DB_URL: z.preprocess(
|
||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||
z.string().optional(),
|
||
), // mysql://... — not a strict URL per WHATWG
|
||
// Per-source kill switches under the master CATALOG_SOURCE_DB_ENABLED.
|
||
// EMEX_SOURCE_DB_ENABLED keeps the connection pool alive but, per the
|
||
// 2026-06-01 safety audit, fetchCategoryParts ALWAYS returns null unless the
|
||
// requested catalogCode is also in EMEX_SOURCE_DB_ALLOWED_CATALOGS. The
|
||
// catalog-wide bridge measured 7-114x noiseRatio across every catalog and
|
||
// 49-98 wrong-OEM per 100 served — direct violation of the "always correct
|
||
// OEM" rule. Default allowlist is EMPTY → behaviour is safe by default; the
|
||
// master/emex switches stay default-on so the service is ready for the
|
||
// per-vehicle unique_key bridge (follow-up work).
|
||
// PCAT_SOURCE_DB_ENABLED defaults FALSE — verified 2026-06-01 that the dump's
|
||
// deep-scrape covers a US/JDM market subset (Toyota/Nissan/Audi/Chevy/Hyundai)
|
||
// that doesn't intersect sase's TR-market vehicle pool (0 / 103 dev carIds
|
||
// had real parts data through either bridge). Container stays running for
|
||
// future use cases (OEM cross-ref, alt-part search).
|
||
EMEX_SOURCE_DB_ENABLED: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("true"),
|
||
PCAT_SOURCE_DB_ENABLED: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("false"),
|
||
// Per-catalog parts-lookup allowlist. Comma-separated catalog codes
|
||
// (e.g. "RENAULT201910,FFIAT84"). Empty (default) → fetchCategoryParts
|
||
// always returns null → live emex handles every request. A catalog SHOULD
|
||
// only be added here AFTER its per-vehicle bridge (unique_key) is wired and
|
||
// verified against live OEM-by-OEM on at least 5 sampled vehicles. See
|
||
// memory `sase-emex-source-db-safety.md` for the bridge inventory & audit.
|
||
EMEX_SOURCE_DB_ALLOWED_CATALOGS: z.string().default(""),
|
||
});
|
||
|
||
export type Env = z.infer<typeof envSchema>;
|
||
|
||
export function validateEnv(
|
||
env: Record<string, unknown> = process.env as Record<string, unknown>,
|
||
): Env {
|
||
const result = envSchema.safeParse(env);
|
||
if (!result.success) {
|
||
const formatted = result.error.format();
|
||
console.error("Environment validation failed:");
|
||
console.error(JSON.stringify(formatted, null, 2));
|
||
throw new Error("Invalid environment variables");
|
||
}
|
||
return result.data;
|
||
}
|