← 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
A .env.exampleA .gitignoreA data/assets/.gitkeepA db/index.jsA db/migrate.jsA db/schema.sqlA lib/env.jsA lib/scoring.jsA lib/types.jsA package-lock.jsonA package.jsonA server.js
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 →