[object Object]

← back to Rentv Adintel

spine: scaffold RENTV Advertiser Intelligence (Express+pg house stack)

fb8365211be4a203030190341fdb1b656a35d7ca · 2026-08-07 16:19:12 -0700 · Steve Abrams

- full canonical schema (49 tables, spec §11) + idempotent migration runner
- shared contract: lib/types (classification/category/role enums, normalizeName),
  lib/scoring (exact §13 opportunity-score formula + explainScore)
- server.js skeleton with graceful optional route mounting, CSP/secure headers
- zero-dep .env loader, .env.example (§30), .gitignore
- db on local /tmp socket, database rentv_advertisers (migrated clean)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit fb8365211be4a203030190341fdb1b656a35d7ca
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 7 16:19:12 2026 -0700

    spine: scaffold RENTV Advertiser Intelligence (Express+pg house stack)
    
    - full canonical schema (49 tables, spec §11) + idempotent migration runner
    - shared contract: lib/types (classification/category/role enums, normalizeName),
      lib/scoring (exact §13 opportunity-score formula + explainScore)
    - server.js skeleton with graceful optional route mounting, CSP/secure headers
    - zero-dep .env loader, .env.example (§30), .gitignore
    - db on local /tmp socket, database rentv_advertisers (migrated clean)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .env.example         |  58 +++
 .gitignore           |  14 +
 data/assets/.gitkeep |   0
 db/index.js          |  43 +++
 db/migrate.js        |  64 ++++
 db/schema.sql        | 622 ++++++++++++++++++++++++++++++++
 lib/env.js           |  25 ++
 lib/scoring.js       |  56 +++
 lib/types.js         | 146 ++++++++
 package-lock.json    | 978 +++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json         |  31 ++
 server.js            |  70 ++++
 12 files changed, 2107 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..a6c209c
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,58 @@
+# RENTV Advertiser Intelligence — environment (spec §30)
+# Copy to .env and fill in. Secrets are server-only and redacted from logs.
+
+NEXT_PUBLIC_APP_NAME="RENTV Advertiser Intelligence"
+NEXT_PUBLIC_SIMPLE_VIEW_DEFAULT="true"
+APP_BASE_URL="http://localhost:9814"
+PORT="9814"
+AUTH_SECRET="replace-me"
+
+# Postgres — house convention is the local /tmp socket, db rentv_advertisers.
+# DATABASE_URL overrides the socket settings when set (prod/CI).
+DATABASE_URL=""
+PGHOST="/tmp"
+PGDATABASE="rentv_advertisers"
+
+REDIS_URL="redis://localhost:6379"
+OBJECT_STORAGE_DRIVER="local"
+OBJECT_STORAGE_LOCAL_DIR="./data/assets"
+
+# Search provider — manual by default. Never scrape result HTML (§9).
+SEARCH_PROVIDER="manual"
+GOOGLE_CSE_API_KEY=""
+GOOGLE_CSE_ID=""
+BRAVE_SEARCH_API_KEY=""
+BING_SEARCH_API_KEY=""
+SERPER_API_KEY=""
+
+# Google Analytics Data API (GA4) + Search Console — official APIs only (§17-18).
+GOOGLE_SERVICE_ACCOUNT_JSON_BASE64=""
+GA4_PROPERTY_ID=""
+GSC_SITE_URL=""
+
+# Optional Google Ads (§19) — off unless configured.
+GOOGLE_ADS_ENABLED="false"
+GOOGLE_ADS_DEVELOPER_TOKEN=""
+GOOGLE_ADS_CUSTOMER_ID=""
+GOOGLE_ADS_LOGIN_CUSTOMER_ID=""
+GOOGLE_ADS_CLIENT_ID=""
+GOOGLE_ADS_CLIENT_SECRET=""
+GOOGLE_ADS_REFRESH_TOKEN=""
+
+# Gmail import (§16) — admin-only, disabled by default.
+GMAIL_IMPORT_ENABLED="false"
+GMAIL_CLIENT_ID=""
+GMAIL_CLIENT_SECRET=""
+GMAIL_REFRESH_TOKEN=""
+GMAIL_IMPORT_QUERY=""
+
+# Optional local LLM extraction (§16) — Ollama, no hosted model required.
+OLLAMA_ENABLED="false"
+OLLAMA_BASE_URL="http://127.0.0.1:11434"
+OLLAMA_MODEL="qwen2.5:7b"
+
+# Research crawler defaults — conservative, descriptive UA with admin contact.
+CRAWLER_USER_AGENT="RENTV-Advertiser-Research/1.0 (+admin@rentv.com)"
+ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH="false"
+DEFAULT_REQUESTS_PER_MINUTE="6"
+EXPORT_MAX_ROWS="100000"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7adb71b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+node_modules/
+.env
+.env.*
+!.env.example
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/assets/*
+!data/assets/.gitkeep
+exports/
+coverage/
diff --git a/data/assets/.gitkeep b/data/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/db/index.js b/db/index.js
new file mode 100644
index 0000000..86d778b
--- /dev/null
+++ b/db/index.js
@@ -0,0 +1,43 @@
+'use strict';
+/**
+ * Postgres connection pool — the single source of truth (spec §5).
+ * House convention: local unix socket at /tmp, database `rentv_advertisers`.
+ * DATABASE_URL overrides for production / CI.
+ */
+const { Pool } = require('pg');
+
+const pool = process.env.DATABASE_URL
+  ? new Pool({ connectionString: process.env.DATABASE_URL })
+  : new Pool({
+      host: process.env.PGHOST || '/tmp',
+      database: process.env.PGDATABASE || 'rentv_advertisers',
+      user: process.env.PGUSER || process.env.USER,
+      max: 10,
+    });
+
+pool.on('error', (err) => {
+  // eslint-disable-next-line no-console
+  console.error('[db] idle client error', err.message);
+});
+
+async function query(text, params) {
+  return pool.query(text, params);
+}
+
+/** Convenience: run inside a transaction, auto rollback on throw. */
+async function tx(fn) {
+  const client = await pool.connect();
+  try {
+    await client.query('BEGIN');
+    const out = await fn(client);
+    await client.query('COMMIT');
+    return out;
+  } catch (e) {
+    await client.query('ROLLBACK');
+    throw e;
+  } finally {
+    client.release();
+  }
+}
+
+module.exports = { pool, query, tx };
diff --git a/db/migrate.js b/db/migrate.js
new file mode 100644
index 0000000..104a6f7
--- /dev/null
+++ b/db/migrate.js
@@ -0,0 +1,64 @@
+'use strict';
+/**
+ * Migration runner. Applies db/schema.sql (idempotent) plus any numbered
+ * files in db/migrations/*.sql not yet recorded in schema_migrations.
+ * Usage:  node db/migrate.js [--reset]
+ *   --reset drops and recreates the public schema first (DESTRUCTIVE, local dev only).
+ */
+const fs = require('fs');
+const path = require('path');
+const { pool } = require('./index');
+
+async function main() {
+  const reset = process.argv.includes('--reset');
+  const client = await pool.connect();
+  try {
+    if (reset) {
+      // eslint-disable-next-line no-console
+      console.log('[migrate] --reset: dropping public schema');
+      await client.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;');
+    }
+
+    const baseSql = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
+    await client.query(baseSql);
+    console.log('[migrate] base schema applied');
+
+    // ensure bookkeeping table exists (schema.sql creates it too)
+    await client.query(
+      'CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())'
+    );
+
+    const migDir = path.join(__dirname, 'migrations');
+    const files = fs.existsSync(migDir)
+      ? fs.readdirSync(migDir).filter((f) => f.endsWith('.sql')).sort()
+      : [];
+    for (const f of files) {
+      const version = f.replace(/\.sql$/, '');
+      const { rowCount } = await client.query(
+        'SELECT 1 FROM schema_migrations WHERE version=$1',
+        [version]
+      );
+      if (rowCount) continue;
+      const sql = fs.readFileSync(path.join(migDir, f), 'utf8');
+      await client.query('BEGIN');
+      try {
+        await client.query(sql);
+        await client.query('INSERT INTO schema_migrations(version) VALUES ($1)', [version]);
+        await client.query('COMMIT');
+        console.log(`[migrate] applied ${f}`);
+      } catch (e) {
+        await client.query('ROLLBACK');
+        throw e;
+      }
+    }
+    console.log('[migrate] done');
+  } finally {
+    client.release();
+    await pool.end();
+  }
+}
+
+main().catch((e) => {
+  console.error('[migrate] FAILED', e.message);
+  process.exit(1);
+});
diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 0000000..e868796
--- /dev/null
+++ b/db/schema.sql
@@ -0,0 +1,622 @@
+-- RENTV Advertiser Intelligence — canonical PostgreSQL schema (spec §11)
+-- Postgres is the single source of truth. UUIDs internal; source-native ids preserved.
+-- Idempotent: safe to re-run. Uses pgcrypto gen_random_uuid + pg_trgm for search.
+
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+-- ---------------------------------------------------------------------------
+-- Enumerated domains (kept as CHECK-constrained text so seed/migration stay simple)
+-- Canonical value lists also live in lib/types.js (the shared JS contract).
+-- ---------------------------------------------------------------------------
+
+-- ============================ ORGANIZATIONS ================================
+CREATE TABLE IF NOT EXISTS organizations (
+  id                   uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  legal_name           text,
+  display_name         text NOT NULL,
+  normalized_name      text NOT NULL,
+  aliases              jsonb NOT NULL DEFAULT '[]'::jsonb,
+  domain               text,
+  organization_type    text,
+  advertiser_categories jsonb NOT NULL DEFAULT '[]'::jsonb,
+  description          text,
+  headquarters_state   text,
+  headquarters_city    text,
+  active_status        text NOT NULL DEFAULT 'ACTIVE',
+  logo_asset_id        uuid,
+  first_seen_at        timestamptz,
+  last_seen_at         timestamptz,
+  created_at           timestamptz NOT NULL DEFAULT now(),
+  updated_at           timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_org_normname_trgm ON organizations USING gin (normalized_name gin_trgm_ops);
+CREATE INDEX IF NOT EXISTS idx_org_domain ON organizations (domain);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_org_normname_domain ON organizations (normalized_name, coalesce(domain,''));
+
+-- ============================ PEOPLE / CONTACTS ============================
+CREATE TABLE IF NOT EXISTS people (
+  id                          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  full_name                   text NOT NULL,
+  normalized_name             text NOT NULL,
+  public_title                text,
+  organization_id             uuid REFERENCES organizations(id) ON DELETE SET NULL,
+  role_category               text,               -- CMO / VP_MARKETING / MARKETING_DIRECTOR / ...
+  city                        text,
+  state                       text,
+  linkedin_url                text,
+  linkedin_discovery_source_id uuid,
+  linkedin_review_status      text NOT NULL DEFAULT 'UNREVIEWED',
+  last_verified_at            timestamptz,
+  created_at                  timestamptz NOT NULL DEFAULT now(),
+  updated_at                  timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_people_org ON people (organization_id);
+
+CREATE TABLE IF NOT EXISTS contact_points (
+  id                 uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  organization_id    uuid REFERENCES organizations(id) ON DELETE CASCADE,
+  person_id          uuid REFERENCES people(id) ON DELETE SET NULL,
+  type               text NOT NULL,   -- BUSINESS_EMAIL, BUSINESS_PHONE, CONTACT_FORM, WEBSITE, LINKEDIN, OTHER_PUBLIC_PROFILE
+  value              text NOT NULL,
+  normalized_value   text,
+  source_evidence_id uuid,
+  explicitly_public  boolean NOT NULL DEFAULT false,
+  verified_at        timestamptz,
+  confidence         numeric(4,3) NOT NULL DEFAULT 0.5,
+  do_not_contact     boolean NOT NULL DEFAULT false,
+  export_allowed     boolean NOT NULL DEFAULT true,
+  created_at         timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_cp_org ON contact_points (organization_id);
+CREATE INDEX IF NOT EXISTS idx_cp_type ON contact_points (type);
+
+-- ============================ GEOGRAPHY ====================================
+CREATE TABLE IF NOT EXISTS markets (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  state           text NOT NULL,
+  region          text,
+  county          text,
+  city            text,
+  metro           text,
+  normalized_name text NOT NULL,
+  priority        integer NOT NULL DEFAULT 999,
+  created_at      timestamptz NOT NULL DEFAULT now()
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_market_norm ON markets (normalized_name);
+
+CREATE TABLE IF NOT EXISTS organization_markets (
+  organization_id   uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+  market_id         uuid NOT NULL REFERENCES markets(id) ON DELETE CASCADE,
+  relationship_type text NOT NULL DEFAULT 'OPERATES_IN',
+  confidence        numeric(4,3) NOT NULL DEFAULT 0.5,
+  evidence_id       uuid,
+  PRIMARY KEY (organization_id, market_id, relationship_type)
+);
+
+-- ============================ PUBLICATIONS / PLACEMENTS =====================
+CREATE TABLE IF NOT EXISTS publications (
+  id                      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  name                    text NOT NULL,
+  domain                  text,
+  publication_type        text,
+  market_scope            text,
+  media_kit_url           text,
+  advertising_contact_url text,
+  source_policy_id        uuid,
+  created_at              timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS placements (
+  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  publication_id      uuid REFERENCES publications(id) ON DELETE CASCADE,
+  name                text NOT NULL,
+  channel             text NOT NULL,   -- WEBSITE, NEWSLETTER, EBLAST, VIDEO, PODCAST, EVENT, PRINT, SOCIAL, OTHER
+  size_or_format      text,
+  market              text,
+  list_size_snapshot  integer,
+  public_rate         numeric(12,2),
+  currency            text DEFAULT 'USD',
+  rate_effective_date date,
+  source_evidence_id  uuid,
+  created_at          timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS campaigns (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
+  publication_id  uuid REFERENCES publications(id) ON DELETE SET NULL,
+  campaign_name   text,
+  campaign_type   text,
+  start_date      date,
+  end_date        date,
+  status          text,
+  destination_url text,
+  utm_source      text,
+  utm_medium      text,
+  utm_campaign    text,
+  notes           text,
+  created_at      timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS creative_assets (
+  id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  organization_id   uuid REFERENCES organizations(id) ON DELETE SET NULL,
+  file_name         text NOT NULL,
+  mime_type         text,
+  width             integer,
+  height            integer,
+  checksum          text,
+  perceptual_hash   text,
+  object_key        text,
+  source_image_url  text,
+  capture_method    text,   -- DOM_IMAGE, PAGE_SCREENSHOT, REGION_CROP, EMAIL_ATTACHMENT, MANUAL_UPLOAD, GENERATED_PLACEHOLDER
+  rights_status     text NOT NULL DEFAULT 'UNKNOWN',  -- INTERNAL_EVIDENCE_ONLY, EXPORT_ALLOWED, LINK_ONLY, UNKNOWN
+  captured_at       timestamptz,
+  alt_text          text,
+  created_at        timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS ad_sightings (
+  id                        uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  organization_id           uuid REFERENCES organizations(id) ON DELETE CASCADE,
+  publication_id            uuid REFERENCES publications(id) ON DELETE SET NULL,
+  campaign_id               uuid REFERENCES campaigns(id) ON DELETE SET NULL,
+  placement_id              uuid REFERENCES placements(id) ON DELETE SET NULL,
+  relationship_status       text NOT NULL DEFAULT 'RESEARCH_NEEDED',
+  source_page_url           text,
+  ad_click_url              text,
+  landing_url               text,
+  tracking_url              text,
+  headline                  text,
+  visible_copy              text,
+  observed_at               timestamptz,
+  first_observed_at         timestamptz,
+  last_observed_at          timestamptz,
+  market_id                 uuid REFERENCES markets(id) ON DELETE SET NULL,
+  creative_asset_id         uuid REFERENCES creative_assets(id) ON DELETE SET NULL,
+  full_screenshot_asset_id  uuid REFERENCES creative_assets(id) ON DELETE SET NULL,
+  thumbnail_asset_id        uuid REFERENCES creative_assets(id) ON DELETE SET NULL,
+  evidence_id               uuid,
+  verified_by_user_id       uuid,
+  verification_status       text NOT NULL DEFAULT 'UNVERIFIED',
+  confidence                numeric(4,3) NOT NULL DEFAULT 0.5,
+  created_at                timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_ad_org ON ad_sightings (organization_id);
+CREATE INDEX IF NOT EXISTS idx_ad_status ON ad_sightings (relationship_status);
+
+-- ============================ EVENTS / SPONSORSHIPS ========================
+CREATE TABLE IF NOT EXISTS events (
+  id                       uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  organizer_organization_id uuid REFERENCES organizations(id) ON DELETE SET NULL,
+  publication_id           uuid REFERENCES publications(id) ON DELETE SET NULL,
+  name                     text NOT NULL,
+  event_type               text,
+  start_date               date,
+  end_date                 date,
+  venue                    text,
+  city                     text,
+  state                    text,
+  market_id                uuid REFERENCES markets(id) ON DELETE SET NULL,
+  official_url             text,
+  sponsor_page_url         text,
+  exhibitor_page_url       text,
+  program_url              text,
+  registration_url         text,
+  source_evidence_id       uuid,
+  created_at               timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS event_relationships (
+  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  event_id            uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+  organization_id     uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+  person_id           uuid REFERENCES people(id) ON DELETE SET NULL,
+  relationship_status text NOT NULL DEFAULT 'SPEAKER_OR_PANELIST_ONLY',
+  sponsor_level       text,
+  booth_number        text,
+  session_title       text,
+  panel_role          text,
+  observed_at         timestamptz,
+  evidence_id         uuid,
+  confidence          numeric(4,3) NOT NULL DEFAULT 0.5,
+  created_at          timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_evrel_event ON event_relationships (event_id);
+CREATE INDEX IF NOT EXISTS idx_evrel_org ON event_relationships (organization_id);
+
+-- ============================ SOURCES / PROVENANCE =========================
+CREATE TABLE IF NOT EXISTS source_policies (
+  id                       uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  source_key               text UNIQUE NOT NULL,
+  display_name             text NOT NULL,
+  owner                    text,
+  base_url                 text,
+  access_method            text NOT NULL,   -- official_api, official_bulk_download, rss_or_sitemap, first_party_public_web, authorized_mailbox, manual_upload, manual_review_only
+  terms_url                text,
+  robots_url               text,
+  allows_automated_access  boolean NOT NULL DEFAULT false,
+  allows_screenshot_capture boolean NOT NULL DEFAULT false,
+  allows_internal_storage  boolean NOT NULL DEFAULT true,
+  allows_export            boolean NOT NULL DEFAULT false,
+  prohibited_hosts         jsonb NOT NULL DEFAULT '[]'::jsonb,
+  permitted_paths          jsonb NOT NULL DEFAULT '[]'::jsonb,
+  prohibited_paths         jsonb NOT NULL DEFAULT '[]'::jsonb,
+  max_requests_per_minute  integer,
+  minimum_delay_ms         integer,
+  retention_days           integer,
+  reviewed_at              timestamptz,
+  review_notes             text,
+  enabled                  boolean NOT NULL DEFAULT false,
+  created_at               timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS sources (
+  id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  source_policy_id uuid REFERENCES source_policies(id) ON DELETE SET NULL,
+  external_id      text,
+  url              text,
+  title            text,
+  discovered_at    timestamptz,
+  event_date       date,
+  metadata         jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at       timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS source_documents (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  source_id     uuid REFERENCES sources(id) ON DELETE CASCADE,
+  content_type  text,
+  object_key    text,
+  final_url     text,
+  checksum      text,
+  retrieved_at  timestamptz,
+  parser_version text,
+  created_at    timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS evidence_records (
+  id             uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  evidence_type  text NOT NULL,   -- WEB_PAGE, EMAIL, PDF, IMAGE, SCREENSHOT, CSV, API, MANUAL_NOTE
+  source_url     text,
+  source_title   text NOT NULL,
+  source_owner   text,
+  observed_at    timestamptz,
+  retrieved_at   timestamptz NOT NULL DEFAULT now(),
+  excerpt        text,
+  object_key     text,
+  checksum       text,
+  confidence     numeric(4,3) NOT NULL DEFAULT 0.5,
+  export_allowed boolean NOT NULL DEFAULT false,
+  created_at     timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS field_evidence (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  entity_table  text NOT NULL,
+  entity_id     uuid NOT NULL,
+  field_name    text NOT NULL,
+  field_value   text,
+  evidence_id   uuid REFERENCES evidence_records(id) ON DELETE CASCADE,
+  confidence    numeric(4,3) NOT NULL DEFAULT 0.5,
+  is_conflicting boolean NOT NULL DEFAULT false,
+  created_at    timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_fieldev_entity ON field_evidence (entity_table, entity_id);
+
+CREATE TABLE IF NOT EXISTS ingestion_runs (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  source_key  text,
+  started_at  timestamptz NOT NULL DEFAULT now(),
+  finished_at timestamptz,
+  status      text NOT NULL DEFAULT 'RUNNING',
+  dry_run     boolean NOT NULL DEFAULT false,
+  stats       jsonb NOT NULL DEFAULT '{}'::jsonb,
+  error       text
+);
+
+CREATE TABLE IF NOT EXISTS ingestion_jobs (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  run_id      uuid REFERENCES ingestion_runs(id) ON DELETE CASCADE,
+  job_type    text NOT NULL,
+  payload     jsonb NOT NULL DEFAULT '{}'::jsonb,
+  status      text NOT NULL DEFAULT 'PENDING',
+  attempts    integer NOT NULL DEFAULT 0,
+  last_error  text,
+  created_at  timestamptz NOT NULL DEFAULT now(),
+  updated_at  timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS parser_versions (
+  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  parser_key   text NOT NULL,
+  version      text NOT NULL,
+  notes        text,
+  created_at   timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS source_health_checks (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  source_key    text NOT NULL,
+  checked_at    timestamptz NOT NULL DEFAULT now(),
+  ok            boolean NOT NULL,
+  status_code   integer,
+  latency_ms    integer,
+  freshness_days integer,
+  note          text
+);
+
+CREATE TABLE IF NOT EXISTS manual_review_items (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  review_type   text NOT NULL,   -- LINKEDIN_URL, AD_LIBRARY, MERGE, CROP, SOURCE
+  organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
+  person_id     uuid REFERENCES people(id) ON DELETE CASCADE,
+  payload       jsonb NOT NULL DEFAULT '{}'::jsonb,
+  status        text NOT NULL DEFAULT 'PENDING',
+  discovery_query text,
+  discovered_at timestamptz,
+  reviewed_at   timestamptz,
+  reviewed_by   uuid,
+  created_at    timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS merge_candidates (
+  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  left_id      uuid NOT NULL,
+  right_id     uuid NOT NULL,
+  entity_table text NOT NULL DEFAULT 'organizations',
+  score        numeric(4,3) NOT NULL,
+  signals      jsonb NOT NULL DEFAULT '{}'::jsonb,
+  status       text NOT NULL DEFAULT 'PENDING',
+  created_at   timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS merge_audit (
+  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  kept_id      uuid NOT NULL,
+  merged_id    uuid NOT NULL,
+  entity_table text NOT NULL DEFAULT 'organizations',
+  snapshot     jsonb NOT NULL,          -- full pre-merge state for reversibility
+  reversed     boolean NOT NULL DEFAULT false,
+  performed_by uuid,
+  performed_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- ============================ ANALYTICS ====================================
+CREATE TABLE IF NOT EXISTS analytics_connections (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  kind          text NOT NULL,   -- GA4, GSC, GOOGLE_ADS
+  property_id   text,
+  site_url      text,
+  status        text NOT NULL DEFAULT 'NOT_CONNECTED',
+  is_demo       boolean NOT NULL DEFAULT true,
+  last_import_at timestamptz,
+  created_at    timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS ga4_daily_metrics (
+  metric_date       date NOT NULL,
+  sessions          integer,
+  total_users       integer,
+  new_users         integer,
+  engaged_sessions  integer,
+  engagement_rate   numeric(6,4),
+  avg_engagement_time numeric(10,2),
+  views             integer,
+  event_count       integer,
+  key_events        integer,
+  is_demo           boolean NOT NULL DEFAULT true,
+  PRIMARY KEY (metric_date)
+);
+
+CREATE TABLE IF NOT EXISTS ga4_landing_page_metrics (
+  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  metric_date  date NOT NULL,
+  landing_page text NOT NULL,
+  sessions     integer, users integer, views integer,
+  engaged_sessions integer, engagement_rate numeric(6,4), key_events integer,
+  is_demo      boolean NOT NULL DEFAULT true
+);
+
+CREATE TABLE IF NOT EXISTS ga4_acquisition_metrics (
+  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  metric_date  date NOT NULL,
+  channel_group text, session_source text, session_medium text, session_campaign text,
+  sessions integer, users integer, key_events integer,
+  is_demo boolean NOT NULL DEFAULT true
+);
+
+CREATE TABLE IF NOT EXISTS ga4_geo_metrics (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  metric_date date NOT NULL,
+  country text, region text, city text, market_id uuid REFERENCES markets(id) ON DELETE SET NULL,
+  sessions integer, users integer,
+  is_demo boolean NOT NULL DEFAULT true
+);
+
+CREATE TABLE IF NOT EXISTS gsc_query_metrics (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  metric_date date NOT NULL,
+  query text, country text, device text,
+  clicks integer, impressions integer, ctr numeric(6,4), position numeric(6,2),
+  is_brand boolean, cluster text,
+  is_demo boolean NOT NULL DEFAULT true
+);
+
+CREATE TABLE IF NOT EXISTS gsc_page_metrics (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  metric_date date NOT NULL,
+  page text, country text, device text,
+  clicks integer, impressions integer, ctr numeric(6,4), position numeric(6,2),
+  is_demo boolean NOT NULL DEFAULT true
+);
+
+CREATE TABLE IF NOT EXISTS google_ads_campaign_metrics (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  metric_date date NOT NULL,
+  campaign text, cost_micros bigint, clicks integer, impressions integer,
+  conversions numeric(12,2), currency text DEFAULT 'USD',
+  is_demo boolean NOT NULL DEFAULT true
+);
+
+CREATE TABLE IF NOT EXISTS google_ads_search_term_metrics (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  metric_date date NOT NULL,
+  search_term text, campaign text, clicks integer, impressions integer,
+  cost_micros bigint, conversions numeric(12,2),
+  is_demo boolean NOT NULL DEFAULT true
+);
+
+CREATE TABLE IF NOT EXISTS analytics_import_runs (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  kind        text NOT NULL,
+  started_at  timestamptz NOT NULL DEFAULT now(),
+  finished_at timestamptz,
+  status      text NOT NULL DEFAULT 'RUNNING',
+  source_file text, checksum text, row_count integer, is_demo boolean NOT NULL DEFAULT true,
+  error text
+);
+
+CREATE TABLE IF NOT EXISTS analytics_annotations (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  annotation_date date NOT NULL,
+  note text NOT NULL,
+  created_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- ============================ SALES WORKSPACE ==============================
+CREATE TABLE IF NOT EXISTS opportunity_stages (
+  id       uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  key      text UNIQUE NOT NULL,
+  label    text NOT NULL,
+  sort_order integer NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS opportunities (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+  stage_id        uuid REFERENCES opportunity_stages(id) ON DELETE SET NULL,
+  recommended_product text,
+  recommended_market  text,
+  sales_angle     text,
+  owner_user_id   uuid,
+  created_at      timestamptz NOT NULL DEFAULT now(),
+  updated_at      timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS opportunity_scores (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+  score           integer NOT NULL,
+  factors         jsonb NOT NULL,      -- full AdvertiserOpportunityInput + weights used
+  computed_at     timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_oppscore_org ON opportunity_scores (organization_id);
+
+CREATE TABLE IF NOT EXISTS notes (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
+  person_id       uuid REFERENCES people(id) ON DELETE CASCADE,
+  body            text NOT NULL,
+  is_private      boolean NOT NULL DEFAULT false,
+  author_user_id  uuid,
+  created_at      timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS tasks (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
+  title           text NOT NULL,
+  due_date        date,
+  status          text NOT NULL DEFAULT 'OPEN',
+  assignee_user_id uuid,
+  created_at      timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS saved_views (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  name        text NOT NULL,
+  query_json  jsonb NOT NULL DEFAULT '{}'::jsonb,
+  owner_user_id uuid,
+  created_at  timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS tags (
+  id    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  label text UNIQUE NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS organization_tags (
+  organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+  tag_id          uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+  PRIMARY KEY (organization_id, tag_id)
+);
+
+CREATE TABLE IF NOT EXISTS suppression_requests (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  scope       text NOT NULL,   -- ORGANIZATION, PERSON, CONTACT_POINT, EMAIL, PHONE
+  target_value text,
+  organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
+  person_id   uuid REFERENCES people(id) ON DELETE CASCADE,
+  reason      text,
+  active      boolean NOT NULL DEFAULT true,
+  created_at  timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS exports (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  kind        text NOT NULL DEFAULT 'DOWNLOAD_EVERYTHING',
+  status      text NOT NULL DEFAULT 'PENDING',
+  object_key  text,
+  row_counts  jsonb NOT NULL DEFAULT '{}'::jsonb,
+  requested_by uuid,
+  created_at  timestamptz NOT NULL DEFAULT now(),
+  finished_at timestamptz
+);
+
+CREATE TABLE IF NOT EXISTS audit_logs (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  action      text NOT NULL,
+  entity_table text,
+  entity_id   uuid,
+  actor       text,
+  detail      jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at  timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs (created_at);
+
+-- ============================ RENTV RATE / AUDIENCE SNAPSHOTS ===============
+-- Dated snapshots preserved (never overwritten) per spec §21.
+CREATE TABLE IF NOT EXISTS rentv_rate_snapshots (
+  id             uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  product_key    text NOT NULL,   -- website_banner, newsletter_tile, property_spotlight, cre_talk_sponsorship, ...
+  product_label  text NOT NULL,
+  rate           numeric(12,2),
+  currency       text DEFAULT 'USD',
+  unit           text,            -- per month, per eblast, per event, ...
+  package_notes  text,
+  observed_at    date NOT NULL,
+  effective_date date,
+  source_key     text,
+  evidence_id    uuid REFERENCES evidence_records(id) ON DELETE SET NULL,
+  created_at     timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS rentv_audience_snapshots (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  metric_key    text NOT NULL,   -- net_recipients, open_rate, ...
+  metric_label  text NOT NULL,
+  value_numeric numeric(14,2),
+  value_text    text,
+  observed_at   date NOT NULL,
+  source_key    text,
+  evidence_id   uuid REFERENCES evidence_records(id) ON DELETE SET NULL,
+  created_at    timestamptz NOT NULL DEFAULT now()
+);
+
+-- ============================ MIGRATIONS BOOKKEEPING =======================
+CREATE TABLE IF NOT EXISTS schema_migrations (
+  version    text PRIMARY KEY,
+  applied_at timestamptz NOT NULL DEFAULT now()
+);
diff --git a/lib/env.js b/lib/env.js
new file mode 100644
index 0000000..94341a6
--- /dev/null
+++ b/lib/env.js
@@ -0,0 +1,25 @@
+'use strict';
+/**
+ * Minimal zero-dependency .env loader. Reads ./.env if present and populates
+ * process.env for keys not already set. Keeps the house stack free of dotenv.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const envPath = path.join(__dirname, '..', '.env');
+try {
+  if (fs.existsSync(envPath)) {
+    const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/);
+    for (const line of lines) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
+      if (!m) continue;
+      const key = m[1];
+      let val = m[2];
+      if (/^".*"$/.test(val) || /^'.*'$/.test(val)) val = val.slice(1, -1);
+      if (process.env[key] === undefined) process.env[key] = val;
+    }
+  }
+} catch (e) {
+  // eslint-disable-next-line no-console
+  console.warn('[env] could not load .env:', e.message);
+}
diff --git a/lib/scoring.js b/lib/scoring.js
new file mode 100644
index 0000000..c2cd5cd
--- /dev/null
+++ b/lib/scoring.js
@@ -0,0 +1,56 @@
+'use strict';
+/**
+ * Advertising Opportunity Score (spec §13).
+ * Ranks RENTV *sales opportunity* — NOT personal worth, NOT a secret ad-spend
+ * estimate. Every factor is transparent and the weights are overridable by an
+ * admin. Do not relabel the output as an ad-spend estimate.
+ */
+
+// Default weights — must sum to 1.0. Admin-overridable (§13).
+const DEFAULT_WEIGHTS = Object.freeze({
+  verifiedAdvertising: 0.22,
+  verifiedConferenceSpendSignal: 0.15,
+  recency: 0.12,
+  repeatActivity: 0.1,
+  californiaFit: 0.1,
+  arizonaFit: 0.05,
+  categoryFit: 0.08,
+  rentvAudienceFit: 0.08,
+  contactCompleteness: 0.04,
+  evidenceQuality: 0.06,
+});
+
+const FACTOR_KEYS = Object.freeze(Object.keys(DEFAULT_WEIGHTS));
+
+const clampScore = (value) => Math.max(0, Math.min(100, Number(value) || 0));
+
+/**
+ * @param {Object} input  each factor 0-100
+ * @param {Object} [weights]  optional weight overrides (partial ok)
+ * @returns {number} integer 0-100
+ */
+function calculateAdvertiserOpportunityScore(input, weights) {
+  const w = { ...DEFAULT_WEIGHTS, ...(weights || {}) };
+  let total = 0;
+  for (const k of FACTOR_KEYS) total += clampScore(input[k]) * w[k];
+  return Math.round(total);
+}
+
+/** Returns per-factor contribution so the UI can "Show Me Why" (§25). */
+function explainScore(input, weights) {
+  const w = { ...DEFAULT_WEIGHTS, ...(weights || {}) };
+  const parts = FACTOR_KEYS.map((k) => {
+    const value = clampScore(input[k]);
+    const weight = w[k];
+    return { factor: k, value, weight, contribution: Math.round(value * weight * 100) / 100 };
+  });
+  return { score: calculateAdvertiserOpportunityScore(input, weights), factors: parts, weights: w };
+}
+
+module.exports = {
+  DEFAULT_WEIGHTS,
+  FACTOR_KEYS,
+  clampScore,
+  calculateAdvertiserOpportunityScore,
+  explainScore,
+};
diff --git a/lib/types.js b/lib/types.js
new file mode 100644
index 0000000..304bab5
--- /dev/null
+++ b/lib/types.js
@@ -0,0 +1,146 @@
+'use strict';
+/**
+ * Shared contract — canonical enumerated value lists (spec §2, §8, §10, §14).
+ * Every module (seed, routes, adapters, export) imports from here so the
+ * classification vocabulary has ONE source of truth. Mirrors db/schema.sql
+ * CHECK domains without hard-coding them into the DDL (keeps migrations simple).
+ */
+
+// §2 — advertising relationship status
+const RELATIONSHIP_STATUS = Object.freeze([
+  'VERIFIED_ADVERTISER',
+  'VERIFIED_CONFERENCE_SPONSOR',
+  'VERIFIED_EXHIBITOR',
+  'VERIFIED_MEDIA_PARTNER',
+  'VERIFIED_CONTENT_PARTNER',
+  'SPEAKER_OR_PANELIST_ONLY',
+  'PAST_ADVERTISER',
+  'LIKELY_PROSPECT',
+  'RESEARCH_NEEDED',
+  'DISQUALIFIED',
+]);
+
+// Which statuses count as "verified" for the Verified-Only switch (§25).
+const VERIFIED_STATUSES = Object.freeze([
+  'VERIFIED_ADVERTISER',
+  'VERIFIED_CONFERENCE_SPONSOR',
+  'VERIFIED_EXHIBITOR',
+  'VERIFIED_MEDIA_PARTNER',
+  'VERIFIED_CONTENT_PARTNER',
+]);
+
+// Plain-English labels for Simple View (§25 — no jargon, no color-only badges).
+const STATUS_LABELS = Object.freeze({
+  VERIFIED_ADVERTISER: 'Verified advertiser',
+  VERIFIED_CONFERENCE_SPONSOR: 'Verified conference sponsor',
+  VERIFIED_EXHIBITOR: 'Verified exhibitor',
+  VERIFIED_MEDIA_PARTNER: 'Verified media partner',
+  VERIFIED_CONTENT_PARTNER: 'Content partner (not a paid sponsor)',
+  SPEAKER_OR_PANELIST_ONLY: 'Speaker / panelist only',
+  PAST_ADVERTISER: 'Past advertiser',
+  LIKELY_PROSPECT: 'Likely prospect',
+  RESEARCH_NEEDED: 'Research needed',
+  DISQUALIFIED: 'Disqualified',
+});
+
+// §10 — source access methods
+const SOURCE_ACCESS_METHODS = Object.freeze([
+  'official_api',
+  'official_bulk_download',
+  'rss_or_sitemap',
+  'first_party_public_web',
+  'authorized_mailbox',
+  'manual_upload',
+  'manual_review_only',
+]);
+
+// §9 — search providers (manual default, never scrape result HTML)
+const SEARCH_PROVIDERS = Object.freeze(['google_cse', 'brave', 'bing', 'serper', 'manual']);
+
+// §10 — evidence types
+const EVIDENCE_TYPES = Object.freeze([
+  'WEB_PAGE', 'EMAIL', 'PDF', 'IMAGE', 'SCREENSHOT', 'CSV', 'API', 'MANUAL_NOTE',
+]);
+
+// §11 — creative rights
+const RIGHTS_STATUS = Object.freeze([
+  'INTERNAL_EVIDENCE_ONLY', 'EXPORT_ALLOWED', 'LINK_ONLY', 'UNKNOWN',
+]);
+
+// §14 — contact role priority (index 0 = highest priority)
+const CONTACT_ROLE_PRIORITY = Object.freeze([
+  'CHIEF_MARKETING_OFFICER',
+  'VP_MARKETING',
+  'MARKETING_DIRECTOR',
+  'COMMUNICATIONS_PR_DIRECTOR',
+  'EVENTS_PARTNERSHIPS_SPONSORSHIPS_DIRECTOR',
+  'BUSINESS_DEVELOPMENT_DIRECTOR',
+  'REGIONAL_PRESIDENT_MARKET_LEADER',
+  'MANAGING_DIRECTOR_PRINCIPAL',
+  'PUBLIC_MEDIA_CONTACT',
+  'GENERAL_COMPANY_CONTACT',
+]);
+
+// §8 — advertiser category taxonomy
+const ADVERTISER_CATEGORIES = Object.freeze([
+  'Brokerage and investment sales',
+  'Leasing and tenant representation',
+  'Developer, owner, investor, REIT, and family office',
+  'Commercial bank and credit union',
+  'Debt fund, mortgage bank, private lender, and capital advisor',
+  'Title, escrow, settlement, and 1031 exchange',
+  'Law firm',
+  'Accounting, tax, and advisory',
+  'Architecture, interiors, planning, and design',
+  'General contractor and construction manager',
+  'Engineering, environmental, surveying, and testing',
+  'Property and facility management',
+  'Insurance and risk management',
+  'Appraisal, valuation, and due diligence',
+  'Proptech, data, software, AI, and marketplace',
+  'Security, telecom, access control, energy, solar, and building systems',
+  'Furniture, finishes, materials, wallcovering, and workplace products',
+  'Economic development organization, municipality, port, and public agency',
+  'Coworking, flexible office, and business services',
+  'Auction, disposition, and receivership services',
+  'Recruiting, staffing, education, and certification',
+  'Association, conference, publication, and media company',
+  'Hospitality, venue, catering, transportation, and event vendor',
+  'Other CRE service',
+]);
+
+// LinkedIn hosts that the generic fetcher MUST block (§6.4, §32).
+const LINKEDIN_BLOCKED_HOSTS = Object.freeze([
+  'linkedin.com', 'www.linkedin.com', 'm.linkedin.com', 'lnkd.in',
+]);
+
+/** normalize a company/person name for deterministic matching (§12). */
+function normalizeName(s) {
+  return String(s || '')
+    .toLowerCase()
+    .normalize('NFKD')
+    .replace(/[̀-ͯ]/g, '')
+    .replace(/\b(the|inc|inc\.|llc|l\.l\.c\.|corp|corporation|company|co|co\.|group|lp|l\.p\.|ltd)\b/g, '')
+    .replace(/[^a-z0-9]+/g, ' ')
+    .trim()
+    .replace(/\s+/g, ' ');
+}
+
+function isVerified(status) {
+  return VERIFIED_STATUSES.includes(status);
+}
+
+module.exports = {
+  RELATIONSHIP_STATUS,
+  VERIFIED_STATUSES,
+  STATUS_LABELS,
+  SOURCE_ACCESS_METHODS,
+  SEARCH_PROVIDERS,
+  EVIDENCE_TYPES,
+  RIGHTS_STATUS,
+  CONTACT_ROLE_PRIORITY,
+  ADVERTISER_CATEGORIES,
+  LINKEDIN_BLOCKED_HOSTS,
+  normalizeName,
+  isVerified,
+};
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..c47af8e
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,978 @@
+{
+  "name": "rentv-adintel",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "rentv-adintel",
+      "version": "0.1.0",
+      "dependencies": {
+        "express": "^4.21.2",
+        "pg": "^8.22.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.6",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+      "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/pg": {
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+      "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.15.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+      "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..59508a9
--- /dev/null
+++ b/package.json
@@ -0,0 +1,31 @@
+{
+  "name": "rentv-adintel",
+  "version": "0.1.0",
+  "private": true,
+  "description": "RENTV Advertiser Intelligence Viewer — California-first CRE advertiser/sponsor intelligence (Express + pg + vanilla, house stack)",
+  "main": "server.js",
+  "type": "commonjs",
+  "scripts": {
+    "start": "node server.js",
+    "dev": "node server.js",
+    "db:migrate": "node db/migrate.js",
+    "db:seed": "node db/seed/index.js",
+    "db:reset": "node db/migrate.js --reset && node db/seed/index.js",
+    "test": "node --test test/*.test.js",
+    "research:california": "node scripts/research.js california",
+    "research:arizona": "node scripts/research.js arizona",
+    "research:conferences": "node scripts/research.js conferences",
+    "analytics:ga4": "node scripts/import-google.js ga4",
+    "analytics:gsc": "node scripts/import-google.js gsc",
+    "analytics:google-ads": "node scripts/import-google.js google-ads",
+    "sources:audit": "node scripts/audit-source-policies.js",
+    "export:all": "node scripts/export-all.js"
+  },
+  "dependencies": {
+    "express": "^4.21.2",
+    "pg": "^8.22.0"
+  },
+  "engines": {
+    "node": ">=20"
+  }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..bd2d47b
--- /dev/null
+++ b/server.js
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * RENTV Advertiser Intelligence Viewer — Express entry point (house stack).
+ * Mounts page routes + /api/v1. Simple View is the default executive UX (§25).
+ * One-line module mount pattern (matches the DW/rentv fleet convention).
+ */
+require('./lib/env'); // loads .env into process.env (no dependency)
+
+const path = require('path');
+const express = require('express');
+
+const app = express();
+app.disable('x-powered-by');
+app.use(express.json({ limit: '5mb' }));
+app.use(express.urlencoded({ extended: true, limit: '5mb' }));
+
+// Secure headers + a conservative CSP (§32). Vanilla pages only, no CDNs.
+app.use((req, res, next) => {
+  res.set('X-Content-Type-Options', 'nosniff');
+  res.set('X-Frame-Options', 'SAMEORIGIN');
+  res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
+  res.set(
+    'Content-Security-Policy',
+    "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'"
+  );
+  next();
+});
+
+// Static assets (thumbnails, seed flyers, css, js).
+app.use('/assets', express.static(path.join(__dirname, 'data', 'assets')));
+app.use('/seed', express.static(path.join(__dirname, 'public', 'seed')));
+app.use('/css', express.static(path.join(__dirname, 'public', 'css')));
+app.use('/js', express.static(path.join(__dirname, 'public', 'js')));
+
+app.get('/healthz', (req, res) => res.json({ ok: true, service: 'rentv-adintel', ts: Date.now() }));
+
+// ---- Route modules (each guards its own existence so the app boots even
+// ---- before a parallel worker has landed its file — graceful degradation). ----
+function mountOptional(mountPath, modulePath) {
+  try {
+    const router = require(modulePath);
+    app.use(mountPath, router);
+    // eslint-disable-next-line no-console
+    console.log(`[mount] ${mountPath} -> ${modulePath}`);
+  } catch (e) {
+    if (e.code === 'MODULE_NOT_FOUND' && e.message.includes(modulePath.replace('./', ''))) {
+      console.warn(`[mount] SKIP ${mountPath} (${modulePath} not present yet)`);
+    } else {
+      throw e; // a real error inside the module — surface it
+    }
+  }
+}
+
+mountOptional('/api/v1', './src/routes/api');
+mountOptional('/', './src/routes/pages');
+
+// Fallback landing so a fresh clone shows something before pages/ lands.
+app.get('/', (req, res, next) => {
+  if (res.headersSent) return next();
+  res
+    .type('html')
+    .send('<h1>RENTV Advertiser Intelligence</h1><p>App is booting. Run <code>npm run db:migrate && npm run db:seed</code>, then open <a href="/advertisers">/advertisers</a>.</p>');
+});
+
+const PORT = process.env.PORT || 9814;
+if (require.main === module) {
+  app.listen(PORT, () => console.log(`[rentv-adintel] listening on http://localhost:${PORT}`));
+}
+
+module.exports = app;

(oldest)  ·  back to Rentv Adintel  ·  auto-data-snapshot: 2026-08-07T16:25:56 (10 data files) — do a0476ab →