Files
sase.tr/packages/config/src/index.ts
Semih Yesilyurt 145689a391
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
feat(capi): server-side Meta Conversions API for signup (CompleteRegistration)
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>
2026-06-09 15:19:58 +03:00

195 lines
8.4 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 { 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;
}