social-reporting-tool/v2/server/lib/env.ts
DJP b89e8b511e Add V2: multi-team social-reporting platform with manifest-gated linking
V2 lives entirely under v2/ and is built around three asks the team raised
about V1: per-video assets sometimes drifted onto the wrong trend, hashtag
scrapes returned junk that wasn't filterable per-client, and there was no
multi-user model behind Microsoft SSO.

Highlights:
- Stable TikTok numeric-id key for every per-video asset; URL form drift is
  logged loudly to drift_log.jsonl and never silently nulls assets. Stage 5
  manifest hard-gates Stage 6 if any selected video is missing any required
  asset; --drop-failing auto-backfills from the next-best recipe candidates.
- Per-brief engagement floor (min_likes / min_plays / min_stl_pct), applied
  at Apify scrape time and re-validated locally; spend_log.json records
  raw_returned vs kept_after_floor per scrape.
- Users + teams + memberships with owner/admin/editor/viewer roles; SSO
  upserts a user keyed on Azure oid, auto-creates a personal team, and a
  super-admin is bootstrapped via BOOTSTRAP_SUPER_ADMIN_EMAIL on first
  sign-in. Phase A integration test: 16/16 pass.
- 10-stage TS pipeline (brief → seed → scrape1 → select → scrape2 →
  validate → analyse → insights → trends → qa → build) wired through one
  CLI; each stage idempotent + resumable from disk via .state sentinels.
  §4.5 rubrics shipped under prompts/ and loaded into Claude calls.
- React 18 + Vite + TS + Tailwind operator SPA: brief intake form,
  team management, super-admin user list, help/FAQ ported from V1.
- Separate Docker Compose project (name: social-reporting-v2, port 3457,
  Postgres 5437) with deploy/setup-v2.sh, deploy-v2.sh, rollback-to-v1.sh
  scripts that take over V1's /social-reports URL and let us roll back.

Verification: 62 unit tests pass (auth/session, ids extractor with full URL
fixture, engagement floor, recipes, manifest, linking-fix, MoM compare).
Live smoke run on a Dove brief: 1400 raw → 253 kept (82% culled) → 21
fully-bundled videos → 25 editorial trends across 8 brief-driven categories,
with drift=0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:39:07 -04:00

50 lines
1.4 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function loadDotenv(): Record<string, string> {
const env: Record<string, string> = {};
const candidates = [
resolve(__dirname, '../../.env'),
resolve(__dirname, '../../../.env'),
resolve(process.cwd(), '.env'),
];
for (const p of candidates) {
try {
const txt = readFileSync(p, 'utf-8');
for (const raw of txt.split('\n')) {
const t = raw.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq === -1) continue;
env[t.slice(0, eq).trim()] = t.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
}
break;
} catch { /* try next */ }
}
return env;
}
const fileEnv = loadDotenv();
export function envStr(key: string, fallback = ''): string {
return process.env[key] ?? fileEnv[key] ?? fallback;
}
export function envInt(key: string, fallback: number): number {
const v = envStr(key);
if (!v) return fallback;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : fallback;
}
export function envBool(key: string, fallback = false): boolean {
const v = envStr(key).toLowerCase();
if (!v) return fallback;
return v === 'true' || v === '1' || v === 'yes';
}
export const IS_PRODUCTION = envStr('NODE_ENV') === 'production';