Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
Dev'de ölçüldü: upstream 429 gövdesi "Customer Hourly Request Limit Exceeded", ~110 çağrı sonra; 47 dk sonraki denemede hâlâ 429 (kayan saatlik kota, sabit kilit değil). Kilide çarpıp her araç için baştan başlamak yerine: - Throttle: `CARCATONLINE_HOURLY_CALL_CAP` (varsayılan 90) — Istanbul saat dilimi başına Redis sayaç; dolunca iş kendini bir sonraki saat başına erteler. - Tarama cache'i: groups2 cevapları Redis'te 24 s (`carcatonline:groups:*`); tekrar koşan iş cache'ten okur, çağrı harcamaz. - Varyant çözümü metadata'ya `status:"matched"` olarak yazılır; tarama saat ortasında kesilirse sonraki koşu kademeli seçimi atlar. Scan 'matched' satırları 7 günlük bekleme olmadan yeniden kuyruklar. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
293 lines
13 KiB
TypeScript
293 lines
13 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(),
|
||
|
||
// Vinpin ePER decode-oracle (browser-automated yetkili-servis catalog).
|
||
// Master feature flag — OFF by default. When false the whole driver/queue/
|
||
// integration is a strict no-op (decodeVin behaviour is byte-identical to
|
||
// before). Auth/URL come from env so the paid seat can be swapped in later
|
||
// with ZERO code change. See integrations/vinpin.
|
||
VINPIN_ENABLED: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("false"),
|
||
VINPIN_URL: z.string().url().default("https://login.tr.vinpin.online"),
|
||
VINPIN_USER: z.string().optional(),
|
||
VINPIN_PASS: z.string().optional(),
|
||
// Per-VIN decode retry budget inside the driver before giving up (-> not_found).
|
||
VINPIN_DECODE_MAX_ATTEMPTS: z.coerce.number().int().min(1).default(3),
|
||
// Run the Vinpin browser headful (xvfb path). Default headless, matching the
|
||
// emex browser which already runs headless in the deployed image.
|
||
VINPIN_HEADFUL: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("false"),
|
||
|
||
// RPartStore (rpartstore.renault.com) Renault/Dacia VIN-decode fallback — runs
|
||
// AFTER the pcat/PL24/emex race, before Vinpin. Off by default; the worker
|
||
// needs the dealer credentials. RPARTSTORE_DAILY_CAP is a hard ceiling on VIN
|
||
// searches sent per Istanbul day (the portal itself also rate-limits 2/10 s).
|
||
RPARTSTORE_ENABLED: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("false"),
|
||
RPARTSTORE_USER: z.string().optional(),
|
||
RPARTSTORE_PASS: z.string().optional(),
|
||
RPARTSTORE_DAILY_CAP: z.coerce.number().int().min(0).default(10),
|
||
RPARTSTORE_BROKER_URL: z.preprocess(
|
||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||
z.string().url().default("wss://1po-bff.renault-edh.com/ws"),
|
||
),
|
||
RPARTSTORE_APP_VERSION: z.preprocess(
|
||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||
z.string().default("1.34.0.6"),
|
||
),
|
||
|
||
// carcatonline (pro.carcatonline.com, PartsLink24 mirror) — night-window
|
||
// category backfill for empty PL24 catalog vehicles + on-demand parts for the
|
||
// seeded leaves. Shared Redis pacing/lockout/daily budget across api+worker.
|
||
CARCATONLINE_ENABLED: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("false"),
|
||
CARCATONLINE_EMAIL: z.string().optional(),
|
||
CARCATONLINE_PASSWORD: z.string().optional(),
|
||
CARCATONLINE_DAILY_CALL_CAP: z.coerce.number().int().min(0).default(15000),
|
||
CARCATONLINE_HOURLY_CALL_CAP: z.coerce.number().int().min(0).default(90),
|
||
CARCATONLINE_WINDOW_START: z.coerce.number().int().min(0).max(23).default(20),
|
||
CARCATONLINE_WINDOW_END: z.coerce.number().int().min(0).max(23).default(7),
|
||
CARCATONLINE_MIN_INTERVAL_MS: z.coerce.number().int().min(500).default(7000),
|
||
CARCATONLINE_LOCKOUT_SECONDS: z.coerce.number().int().min(60).default(2400),
|
||
CARCATONLINE_SCAN_BATCH: z.coerce.number().int().min(1).default(20),
|
||
|
||
// 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,
|
||
// worker translation processor and the VIN photo-OCR endpoint)
|
||
OPENROUTER_API_KEY: z.string().optional(),
|
||
|
||
// VIN photo-OCR (POST /vehicles/decode/ocr) — vision models called through
|
||
// OpenRouter with the key above. Bake-off 2026-07-09: gemini-2.5-flash reads
|
||
// type plates/stickers reliably (~$0.0005/photo); flash-lite mangles digits
|
||
// on hard shots, so don't downgrade. Fallback runs only when the primary
|
||
// returns no valid 17-char VIN — cross-provider for failure independence.
|
||
VIN_OCR_MODEL: z.preprocess(
|
||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||
z.string().default("google/gemini-2.5-flash"),
|
||
),
|
||
VIN_OCR_FALLBACK_MODEL: z.preprocess(
|
||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||
z.string().default("openai/gpt-5.4-nano"),
|
||
),
|
||
|
||
// 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(),
|
||
// Unsubscribe (List-Unsubscribe header + body footer links). When the secret
|
||
// is unset, mails carry the mailto: variant only and one-click POSTs are
|
||
// rejected — fine for dev, must be set in prod.
|
||
UNSUBSCRIBE_SECRET: z.string().optional(),
|
||
// "" → undefined preprocess: compose ships `${VAR:-}` so unset values arrive
|
||
// as empty strings, and a bare .url()/.email() would crash boot on "".
|
||
UNSUBSCRIBE_URL_BASE: z.preprocess(
|
||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||
z.string().url().optional(),
|
||
),
|
||
UNSUBSCRIBE_EMAIL: z.preprocess(
|
||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||
z.string().email().default("unsubscribe@sase.tr"),
|
||
),
|
||
|
||
// 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(""),
|
||
|
||
// Tedarikçi fiyat verisi (takip MySQL, Tailscale üzerinden). Kapalı/boş URL →
|
||
// part-prices uçları { matched: false } döner, UI fiyat bölümünü hiç çizmez.
|
||
SUPPLIER_PRICE_DB_ENABLED: z
|
||
.string()
|
||
.transform((v) => v === "true")
|
||
.default("false"),
|
||
SUPPLIER_PRICE_DB_URL: z.preprocess(
|
||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||
z.string().optional(),
|
||
), // mysql://... — not a strict URL per WHATWG
|
||
});
|
||
|
||
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;
|
||
}
|