← back to Rentv
pr-intelligence: CA seed (178 curated + FDIC registry), unit+integration tests (28 passing), merge NULL-array fix, FDIC API host update
d0589dad1428c9d66740089defb9171c50f319d9 · 2026-07-30 11:35:45 -0700 · Steve Abrams
Files touched
M package.jsonM src/pr/adapters/registry.jsA src/pr/seed/ca-seed.jsM src/pr/services/organizations.jsA test/pr/integration.test.jsA test/pr/unit.test.js
Diff
commit d0589dad1428c9d66740089defb9171c50f319d9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 30 11:35:45 2026 -0700
pr-intelligence: CA seed (178 curated + FDIC registry), unit+integration tests (28 passing), merge NULL-array fix, FDIC API host update
---
package.json | 2 +-
src/pr/adapters/registry.js | 3 +-
src/pr/seed/ca-seed.js | 260 +++++++++++++++++++++++++++++++++++++++
src/pr/services/organizations.js | 11 +-
test/pr/integration.test.js | 233 +++++++++++++++++++++++++++++++++++
test/pr/unit.test.js | 152 +++++++++++++++++++++++
6 files changed, 654 insertions(+), 7 deletions(-)
diff --git a/package.json b/package.json
index 977adb6d..ee9273bd 100644
--- a/package.json
+++ b/package.json
@@ -1 +1 @@
-{"name":"rentv","version":"0.10.0","private":true,"main":"server.js","scripts":{"start":"node server.js","pull":"node scripts/pull-news.mjs","pr:migrate":"node -e \"require('./src/pr/db').runMigrations({log:console.log}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(e.message);process.exit(1)})\"","pr:worker":"node src/pr/worker.js","pr:seed:ca":"node src/pr/seed/ca-seed.js","test":"node --test test/pr/"},"dependencies":{"express":"^4.21.2","pg":"^8.22.0"}}
\ No newline at end of file
+{"name":"rentv","version":"0.10.0","private":true,"main":"server.js","scripts":{"start":"node server.js","pull":"node scripts/pull-news.mjs","pr:migrate":"node -e \"require('./src/pr/db').runMigrations({log:console.log}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(e.message);process.exit(1)})\"","pr:worker":"node src/pr/worker.js","pr:seed:ca":"node src/pr/seed/ca-seed.js","test":"node --test test/pr/*.test.js"},"dependencies":{"express":"^4.21.2","pg":"^8.22.0"}}
\ No newline at end of file
diff --git a/src/pr/adapters/registry.js b/src/pr/adapters/registry.js
index 1b4784b2..b193bdea 100644
--- a/src/pr/adapters/registry.js
+++ b/src/pr/adapters/registry.js
@@ -22,7 +22,8 @@ const adapter = register({
*/
async fdicBanks(state, { cursor } = {}) {
const offset = cursor && cursor.offset ? cursor.offset : 0;
- const url = `https://banks.data.fdic.gov/api/institutions?filters=${encodeURIComponent(`STALP:${state} AND ACTIVE:1`)}&fields=NAME,CITY,COUNTY,STALP,WEBADDR,CERT,BKCLASS&limit=100&offset=${offset}&format=json`;
+ // NOTE 2026-07-30: banks.data.fdic.gov now 301s to api.fdic.gov/banks — use the new host.
+ const url = `https://api.fdic.gov/banks/institutions?filters=${encodeURIComponent(`STALP:${state} AND ACTIVE:1`)}&fields=NAME,CITY,COUNTY,STALP,WEBADDR,CERT,BKCLASS&limit=100&offset=${offset}&format=json`;
const errors = []; let items = []; let total = 0;
try {
const j = await throttled('fdic', async () => {
diff --git a/src/pr/seed/ca-seed.js b/src/pr/seed/ca-seed.js
new file mode 100644
index 00000000..9e9d1626
--- /dev/null
+++ b/src/pr/seed/ca-seed.js
@@ -0,0 +1,260 @@
+'use strict';
+// California Phase-1 seed — HONEST BY CONSTRUCTION.
+//
+// Two ingestion paths, both through the real pipeline:
+// 1. discover-organizations job → FDIC registry adapter loads every ACTIVE
+// California bank (official government data, primary source, live API).
+// 2. The curated candidate list below: real, well-known California CRE
+// organizations (brokerages, owners/REITs, title/escrow, lenders, law, AEC,
+// EDCs, associations, proptech, PR/marketing agencies, publications).
+// Each enters as lifecycle_status='discovered' with a low-confidence seed
+// source, then a verify-organization job fetches the LIVE website to
+// confirm it, find press/newsroom/contact pages, and extract public team
+// names+titles. Anything whose site can't be verified STAYS unverified.
+//
+// Run: npm run pr:seed:ca (idempotent — exact matches dedupe, jobs dedupe)
+const db = require('../db');
+const organizations = require('../services/organizations');
+const jobs = require('../jobs');
+
+// [name, domain, type, metro, county] — metro keys from lib/geo.js
+const C = [
+ // ── Brokerage & advisory ──
+ ['CBRE', 'cbre.com', 'brokerage', 'la', 'Los Angeles'],
+ ['JLL', 'jll.com', 'brokerage', 'la', 'Los Angeles'],
+ ['Cushman & Wakefield', 'cushmanwakefield.com', 'brokerage', 'la', 'Los Angeles'],
+ ['Colliers', 'colliers.com', 'brokerage', 'la', 'Los Angeles'],
+ ['Newmark', 'nmrk.com', 'brokerage', 'la', 'Los Angeles'],
+ ['Marcus & Millichap', 'marcusmillichap.com', 'brokerage', 'la', 'Los Angeles'],
+ ['Kidder Mathews', 'kidder.com', 'brokerage', 'la', 'Los Angeles'],
+ ['Lee & Associates', 'lee-associates.com', 'brokerage', 'la', 'Los Angeles'],
+ ['NAI Capital Commercial', 'naicapital.com', 'brokerage', 'la', 'Los Angeles'],
+ ['DAUM Commercial Real Estate Services', 'daumcommercial.com', 'brokerage', 'la', 'Los Angeles'],
+ ['Voit Real Estate Services', 'voitco.com', 'brokerage', 'oc', 'Orange'],
+ ['Avison Young', 'avisonyoung.com', 'brokerage', 'la', 'Los Angeles'],
+ ['Savills North America', 'savills.us', 'brokerage', 'la', 'Los Angeles'],
+ ['Matthews Real Estate Investment Services', 'matthews.com', 'brokerage', 'la', 'Los Angeles'],
+ ['Hanley Investment Group', 'hanleyinvestment.com', 'brokerage', 'oc', 'Orange'],
+ ['Faris Lee Investments', 'farislee.com', 'brokerage', 'oc', 'Orange'],
+ ['Progressive Real Estate Partners', 'progressiverep.com', 'brokerage', 'inland-empire', 'San Bernardino'],
+ ['SVN International', 'svn.com', 'brokerage', 'la', 'Los Angeles'],
+ ['TRI Commercial Real Estate Services', 'tricommercial.com', 'brokerage', 'bay-area', 'San Francisco'],
+ ['Hughes Marino', 'hughesmarino.com', 'brokerage', 'san-diego', 'San Diego'],
+ ['Pearson Realty', 'pearsonrealty.com', 'brokerage', 'central-valley', 'Fresno'],
+ // ── Owners / operators / investors / developers / sponsors ──
+ ['Irvine Company', 'irvinecompany.com', 'developer_owner', 'oc', 'Orange'],
+ ['Related California', 'relatedcalifornia.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Lowe', 'lowe-re.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Lincoln Property Company', 'lpc.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Hines', 'hines.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Tishman Speyer', 'tishmanspeyer.com', 'developer_owner', 'bay-area', 'San Francisco'],
+ ['Brookfield Properties', 'brookfieldproperties.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Majestic Realty Co.', 'majesticrealty.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Watson Land Company', 'watsonlandcompany.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Sares Regis Group', 'saresregis.com', 'developer_owner', 'oc', 'Orange'],
+ ['Shea Properties', 'sheaproperties.com', 'developer_owner', 'oc', 'Orange'],
+ ['The Sobrato Organization', 'sobrato.com', 'developer_owner', 'bay-area', 'Santa Clara'],
+ ['Jay Paul Company', 'jaypaul.com', 'developer_owner', 'bay-area', 'San Francisco'],
+ ['LBA Realty', 'lbarealty.com', 'developer_owner', 'oc', 'Orange'],
+ ['CIM Group', 'cimgroup.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Kearny Real Estate Company', 'kearny.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Panattoni Development Company', 'panattoni.com', 'developer_owner', 'oc', 'Orange'],
+ ['Overton Moore Properties', 'omproperties.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Kennedy Wilson', 'kennedywilson.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Buzz Oates', 'buzzoates.com', 'developer_owner', 'sacramento', 'Sacramento'],
+ ['Investec Real Estate Companies', 'investecre.com', 'developer_owner', 'central-coast', 'Santa Barbara'],
+ ['Ares Management Real Estate', 'aresmgmt.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Oaktree Capital Management', 'oaktreecapital.com', 'developer_owner', 'la', 'Los Angeles'],
+ ['Canyon Partners Real Estate', 'canyonpartners.com', 'developer_owner', 'la', 'Los Angeles'],
+ // ── REITs ──
+ ['Kilroy Realty Corporation', 'kilroyrealty.com', 'reit', 'la', 'Los Angeles'],
+ ['Douglas Emmett', 'douglasemmett.com', 'reit', 'la', 'Los Angeles'],
+ ['Hudson Pacific Properties', 'hudsonpacificproperties.com', 'reit', 'la', 'Los Angeles'],
+ ['Rexford Industrial Realty', 'rexfordindustrial.com', 'reit', 'la', 'Los Angeles'],
+ ['Prologis', 'prologis.com', 'reit', 'bay-area', 'San Francisco'],
+ ['Essex Property Trust', 'essexpropertytrust.com', 'reit', 'bay-area', 'San Mateo'],
+ ['Alexandria Real Estate Equities', 'are.com', 'reit', 'la', 'Los Angeles'],
+ ['Terreno Realty Corporation', 'terreno.com', 'reit', 'bay-area', 'San Francisco'],
+ ['BioMed Realty', 'biomedrealty.com', 'reit', 'san-diego', 'San Diego'],
+ ['Realty Income Corporation', 'realtyincome.com', 'reit', 'san-diego', 'San Diego'],
+ ['American Assets Trust', 'americanassetstrust.com', 'reit', 'san-diego', 'San Diego'],
+ ['BXP', 'bxp.com', 'reit', 'bay-area', 'San Francisco'],
+ // ── Asset / property / facility management ──
+ ['RiverRock Real Estate Group', 'riverrockreg.com', 'asset_property_mgmt', 'oc', 'Orange'],
+ ['Transwestern', 'transwestern.com', 'asset_property_mgmt', 'la', 'Los Angeles'],
+ ['Greystar', 'greystar.com', 'asset_property_mgmt', 'la', 'Los Angeles'],
+ ['Avanath Capital Management', 'avanath.com', 'asset_property_mgmt', 'oc', 'Orange'],
+ ['ABM Industries', 'abm.com', 'facility_mgmt', 'bay-area', 'San Francisco'],
+ // ── Debt & equity / mortgage banking / lenders ──
+ ['Gantry', 'gantryinc.com', 'debt_equity_broker', 'bay-area', 'San Francisco'],
+ ['Northmarq', 'northmarq.com', 'debt_equity_broker', 'la', 'Los Angeles'],
+ ['Berkadia', 'berkadia.com', 'commercial_mortgage_bank', 'la', 'Los Angeles'],
+ ['Walker & Dunlop', 'walkerdunlop.com', 'commercial_mortgage_bank', 'la', 'Los Angeles'],
+ ['PSRS', 'psrs.com', 'commercial_mortgage_bank', 'la', 'Los Angeles'],
+ ['Mesa West Capital', 'mesawestcapital.com', 'private_lender', 'la', 'Los Angeles'],
+ ['ACORE Capital', 'acorecapital.com', 'private_lender', 'bay-area', 'Marin'],
+ ['Thorofare Capital', 'thorofarecapital.com', 'private_lender', 'la', 'Los Angeles'],
+ ['PCCP', 'pccpllc.com', 'private_lender', 'la', 'Los Angeles'],
+ ['Parkview Financial', 'parkviewfinancial.com', 'construction_lender', 'la', 'Los Angeles'],
+ ['Anchor Loans', 'anchorloans.com', 'bridge_lender', 'central-coast', 'Ventura'],
+ ['Bolour Associates', 'bolour.com', 'bridge_lender', 'la', 'Los Angeles'],
+ ['Pacific Life', 'pacificlife.com', 'life_co_lender', 'oc', 'Orange'],
+ // ── Credit unions (CRE-active) ──
+ ['Golden 1 Credit Union', 'golden1.com', 'credit_union', 'sacramento', 'Sacramento'],
+ ['SchoolsFirst Federal Credit Union', 'schoolsfirstfcu.org', 'credit_union', 'oc', 'Orange'],
+ ['Redwood Credit Union', 'redwoodcu.org', 'credit_union', 'bay-area', 'Marin'],
+ ['Patelco Credit Union', 'patelco.org', 'credit_union', 'bay-area', 'Contra Costa'],
+ ['San Diego County Credit Union', 'sdccu.com', 'credit_union', 'san-diego', 'San Diego'],
+ // ── Title / escrow / 1031 ──
+ ['First American Title Insurance Company', 'firstam.com', 'title_insurer', 'oc', 'Orange'],
+ ['Chicago Title', 'chicagotitle.com', 'title_insurer', 'la', 'Los Angeles'],
+ ['Old Republic Title', 'oldrepublictitle.com', 'title_insurer', 'bay-area', 'San Francisco'],
+ ['Stewart Title', 'stewart.com', 'title_insurer', 'la', 'Los Angeles'],
+ ['Lawyers Title', 'lawyerstitle.com', 'title_agency', 'la', 'Los Angeles'],
+ ['Ticor Title', 'ticortitle.com', 'title_agency', 'la', 'Los Angeles'],
+ ['WFG National Title Insurance Company', 'wfgtitle.com', 'title_insurer', 'la', 'Los Angeles'],
+ ['Glen Oaks Escrow', 'glenoaksescrow.com', 'escrow_company', 'la', 'Los Angeles'],
+ ['Granite Escrow & Settlement Services', 'graniteescrow.com', 'escrow_company', 'oc', 'Orange'],
+ ['Escrow of the West', 'escrowofthewest.com', 'escrow_company', 'la', 'Los Angeles'],
+ ['California Escrow Association', 'caescrow.org', 'association', 'sacramento', 'Sacramento'],
+ ['California Land Title Association', 'clta.org', 'association', 'sacramento', 'Sacramento'],
+ ['IPX1031', 'ipx1031.com', 'exchange_1031', 'bay-area', 'San Francisco'],
+ ['First American Exchange Company', 'firstexchange.com', 'exchange_1031', 'oc', 'Orange'],
+ ['Asset Preservation, Inc.', 'apiexchange.com', 'exchange_1031', 'sacramento', 'Placer'],
+ ['Exeter 1031 Exchange Services', 'exeterco.com', 'exchange_1031', 'san-diego', 'San Diego'],
+ // ── Law / accounting / valuation / consulting / appraisal / insurance ──
+ ['Allen Matkins', 'allenmatkins.com', 'law_firm', 'la', 'Los Angeles'],
+ ['Cox, Castle & Nicholson', 'coxcastle.com', 'law_firm', 'la', 'Los Angeles'],
+ ['Miller Starr Regalia', 'msrlegal.com', 'law_firm', 'bay-area', 'Contra Costa'],
+ ['Rutan & Tucker', 'rutan.com', 'law_firm', 'oc', 'Orange'],
+ ['Buchalter', 'buchalter.com', 'law_firm', 'la', 'Los Angeles'],
+ ['Sheppard Mullin', 'sheppardmullin.com', 'law_firm', 'la', 'Los Angeles'],
+ ['Armanino', 'armanino.com', 'accounting_consulting', 'bay-area', 'Contra Costa'],
+ ['GHJ', 'ghjadvisors.com', 'accounting_consulting', 'la', 'Los Angeles'],
+ ['HCVT', 'hcvt.com', 'accounting_consulting', 'la', 'Los Angeles'],
+ ['Moss Adams', 'mossadams.com', 'accounting_consulting', 'la', 'Los Angeles'],
+ ['Integra Realty Resources', 'irr.com', 'appraiser', 'la', 'Los Angeles'],
+ ['Alliant Insurance Services', 'alliant.com', 'insurance_risk', 'oc', 'Orange'],
+ ['Woodruff Sawyer', 'woodruffsawyer.com', 'insurance_risk', 'bay-area', 'San Francisco'],
+ ['Heffernan Insurance Brokers', 'heffins.com', 'insurance_risk', 'bay-area', 'Contra Costa'],
+ ['Lockton', 'lockton.com', 'insurance_risk', 'la', 'Los Angeles'],
+ // ── Architecture / engineering / construction ──
+ ['Gensler', 'gensler.com', 'architecture_firm', 'bay-area', 'San Francisco'],
+ ['Ware Malcomb', 'waremalcomb.com', 'architecture_firm', 'oc', 'Orange'],
+ ['LPA Design Studios', 'lpadesignstudios.com', 'architecture_firm', 'oc', 'Orange'],
+ ['AC Martin', 'acmartin.com', 'architecture_firm', 'la', 'Los Angeles'],
+ ['CO Architects', 'coarchitects.com', 'architecture_firm', 'la', 'Los Angeles'],
+ ['Carrier Johnson + CULTURE', 'carrierjohnson.com', 'architecture_firm', 'san-diego', 'San Diego'],
+ ['HPA Architecture', 'hparchs.com', 'architecture_firm', 'oc', 'Orange'],
+ ['KPFF Consulting Engineers', 'kpff.com', 'engineering_firm', 'la', 'Los Angeles'],
+ ['Englekirk', 'englekirk.com', 'engineering_firm', 'la', 'Los Angeles'],
+ ['Fuscoe Engineering', 'fuscoe.com', 'engineering_firm', 'oc', 'Orange'],
+ ['Psomas', 'psomas.com', 'engineering_firm', 'la', 'Los Angeles'],
+ ['Kimley-Horn', 'kimley-horn.com', 'engineering_firm', 'oc', 'Orange'],
+ ['Swinerton', 'swinerton.com', 'general_contractor', 'bay-area', 'Contra Costa'],
+ ['Webcor', 'webcor.com', 'general_contractor', 'bay-area', 'San Francisco'],
+ ['Hathaway Dinwiddie Construction', 'hdcco.com', 'general_contractor', 'bay-area', 'San Francisco'],
+ ['DPR Construction', 'dpr.com', 'general_contractor', 'bay-area', 'San Mateo'],
+ ['Level 10 Construction', 'level10gc.com', 'general_contractor', 'bay-area', 'Santa Clara'],
+ ['Snyder Langston', 'snyderlangston.com', 'general_contractor', 'oc', 'Orange'],
+ ['C.W. Driver Companies', 'cwdriver.com', 'general_contractor', 'la', 'Los Angeles'],
+ ['MATT Construction', 'mattconstruction.com', 'general_contractor', 'la', 'Los Angeles'],
+ ['Oltmans Construction Co.', 'oltmans.com', 'general_contractor', 'la', 'Los Angeles'],
+ ['Cumming Group', 'cumming-group.com', 'construction_mgmt', 'la', 'Los Angeles'],
+ // ── Economic development / municipal ──
+ ['Los Angeles County Economic Development Corporation', 'laedc.org', 'economic_development', 'la', 'Los Angeles'],
+ ['San Diego Regional Economic Development Corporation', 'sandiegobusiness.org', 'economic_development', 'san-diego', 'San Diego'],
+ ['Greater Sacramento Economic Council', 'greatersacramento.com', 'economic_development', 'sacramento', 'Sacramento'],
+ ['Orange County Business Council', 'ocbc.org', 'economic_development', 'oc', 'Orange'],
+ ['Bay Area Council', 'bayareacouncil.org', 'economic_development', 'bay-area', 'San Francisco'],
+ ['Fresno County Economic Development Corporation', 'fresnoedc.com', 'economic_development', 'central-valley', 'Fresno'],
+ ['Kern Economic Development Corporation', 'kedc.com', 'economic_development', 'central-valley', 'Kern'],
+ ['San Joaquin Partnership', 'sjpnet.org', 'economic_development', 'central-valley', 'San Joaquin'],
+ ['Opportunity Stanislaus', 'opportunitystanislaus.com', 'economic_development', 'central-valley', 'Stanislaus'],
+ ['Inland Empire Economic Partnership', 'ieep.com', 'economic_development', 'inland-empire', 'San Bernardino'],
+ ['Los Angeles City Planning', 'planning.lacity.gov', 'municipal_re', 'la', 'Los Angeles'],
+ ['San Francisco Planning Department', 'sfplanning.org', 'municipal_re', 'bay-area', 'San Francisco'],
+ // ── Associations & chapters ──
+ ['AIR CRE', 'aircre.com', 'association', 'la', 'Los Angeles'],
+ ['NAIOP SoCal', 'naiopsocal.org', 'association', 'la', 'Los Angeles'],
+ ['ULI Los Angeles', 'la.uli.org', 'association', 'la', 'Los Angeles'],
+ ['ULI San Francisco', 'sf.uli.org', 'association', 'bay-area', 'San Francisco'],
+ ['CREW-LA', 'crewla.org', 'association', 'la', 'Los Angeles'],
+ ['BOMA Greater Los Angeles', 'bomagla.org', 'association', 'la', 'Los Angeles'],
+ ['BOMA San Francisco', 'bomasf.org', 'association', 'bay-area', 'San Francisco'],
+ ['California Business Properties Association', 'cbpa.com', 'association', 'sacramento', 'Sacramento'],
+ // ── Proptech / CRE data & software ──
+ ['Procore Technologies', 'procore.com', 'proptech', 'central-coast', 'Santa Barbara'],
+ ['Yardi', 'yardi.com', 'proptech', 'central-coast', 'Santa Barbara'],
+ ['AppFolio', 'appfolio.com', 'proptech', 'central-coast', 'Santa Barbara'],
+ ['Crexi', 'crexi.com', 'proptech', 'la', 'Los Angeles'],
+ ['Measurabl', 'measurabl.com', 'proptech', 'san-diego', 'San Diego'],
+ ['Juniper Square', 'junipersquare.com', 'proptech', 'bay-area', 'San Francisco'],
+ // ── PR / marketing / public affairs serving CRE ──
+ ['The Hoyt Organization', 'hoytorg.com', 'pr_agency', 'la', 'Los Angeles'],
+ ['Brower Group', 'browergroup.com', 'pr_agency', 'oc', 'Orange'],
+ ['Idea Hall', 'ideahall.com', 'pr_agency', 'oc', 'Orange'],
+ ['Murphy O’Brien', 'murphyobrien.com', 'pr_agency', 'la', 'Los Angeles'],
+ ['Antenna Group', 'antennagroup.com', 'pr_agency', 'bay-area', 'San Francisco'],
+ ['Davies Public Affairs', 'daviespublicaffairs.com', 'public_affairs', 'central-coast', 'Santa Barbara'],
+ ['Cerrell Associates', 'cerrell.com', 'public_affairs', 'la', 'Los Angeles'],
+ ['Marathon Communications', 'marathoncommunications.com', 'public_affairs', 'la', 'Los Angeles'],
+ ['Singer Associates', 'singersf.com', 'public_affairs', 'bay-area', 'San Francisco'],
+ ['Lighthouse Public Affairs', 'lighthousesf.com', 'public_affairs', 'bay-area', 'San Francisco'],
+ ['RSM Design', 'rsmdesign.com', 'marketing_agency', 'oc', 'Orange'],
+ ['Selbert Perkins Design', 'selbertperkins.com', 'marketing_agency', 'la', 'Los Angeles'],
+ // ── Trade publications / events ──
+ ['Connect CRE', 'connectcre.com', 'trade_publication', 'la', 'Los Angeles'],
+ ['The Registry', 'theregistrysf.com', 'trade_publication', 'bay-area', 'San Francisco'],
+ ['Los Angeles Business Journal', 'labusinessjournal.com', 'trade_publication', 'la', 'Los Angeles'],
+ ['San Diego Business Journal', 'sdbj.com', 'trade_publication', 'san-diego', 'San Diego'],
+ ['The Real Deal', 'therealdeal.com', 'trade_publication', 'la', 'Los Angeles'],
+ ['Bisnow', 'bisnow.com', 'trade_publication', 'la', 'Los Angeles'],
+ ['GlobeSt', 'globest.com', 'trade_publication', 'la', 'Los Angeles'],
+];
+
+async function main() {
+ const h = await db.health();
+ if (!h.ok) { console.error('DB unavailable: ' + h.reason); process.exit(1); }
+ await db.runMigrations({});
+ let created = 0, matched = 0, verifyJobs = 0;
+ for (const [name, domain, type, metro, county] of C) {
+ const r = await organizations.create({
+ display_name: name,
+ website_url: 'https://www.' + domain,
+ organization_type: type,
+ state_presence: ['CA'], metros: [metro], counties: [county],
+ lifecycle_status: 'discovered',
+ }, {
+ evidence: {
+ source: {
+ source_type: 'manual',
+ source_name: 'CA Phase-1 curated seed list',
+ url: 'https://www.' + domain,
+ short_excerpt: 'Curated candidate entry — real organization pending live-website verification by the pipeline. Not treated as verified until the verify-organization job confirms against the live site.',
+ is_primary_source: false,
+ license_or_usage_note: 'Candidate list only; all facts must be re-verified against primary sources before outreach.',
+ adapter: 'seed',
+ },
+ fields: { organization: 45 },
+ },
+ actor: 'system:ca-seed',
+ });
+ if (r.created) created++; else matched++;
+ // queue live verification for every seeded org (idempotent via dedupe_key)
+ const j = await jobs.enqueue('verify-organization', { organization_id: r.org.id }, { priority: 5, actor: 'system:ca-seed' });
+ if (!j.deduped) verifyJobs++;
+ }
+ // Official-registry discovery (FDIC CA banks) + duplicate sweep + scores + coverage report.
+ await jobs.enqueue('discover-organizations', { state: 'CA', category: 'bank' }, { priority: 3, actor: 'system:ca-seed' });
+ await jobs.enqueue('detect-duplicates', {}, { priority: 8, actor: 'system:ca-seed' });
+ await jobs.enqueue('calculate-scores', {}, { priority: 9, actor: 'system:ca-seed' });
+ await jobs.enqueue('generate-coverage-report', { state: 'CA' }, { priority: 9, actor: 'system:ca-seed' });
+ console.log(`[ca-seed] curated candidates: ${C.length} (created ${created}, matched-existing ${matched})`);
+ console.log(`[ca-seed] verify-organization jobs queued: ${verifyJobs}`);
+ console.log('[ca-seed] queued: FDIC CA-bank discovery, duplicate sweep, score refresh, coverage report');
+ console.log('[ca-seed] start the worker to execute: npm run pr:worker');
+ process.exit(0);
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
diff --git a/src/pr/services/organizations.js b/src/pr/services/organizations.js
index c332665f..6a99a7fa 100644
--- a/src/pr/services/organizations.js
+++ b/src/pr/services/organizations.js
@@ -210,12 +210,13 @@ async function merge(winnerId, loserId, actor) {
for (const k of fill) {
if (!w[k] && l[k]) await client.query(`UPDATE pr_organizations SET ${k}=$1 WHERE id=$2`, [l[k], winnerId]);
}
+ // array_agg over zero rows yields NULL — COALESCE keeps the NOT NULL columns intact
await client.query(`UPDATE pr_organizations SET
- state_presence = (SELECT array_agg(DISTINCT x) FROM unnest(state_presence || $2::text[]) x),
- metros = (SELECT array_agg(DISTINCT x) FROM unnest(metros || $3::text[]) x),
- counties = (SELECT array_agg(DISTINCT x) FROM unnest(counties || $4::text[]) x),
- asset_classes = (SELECT array_agg(DISTINCT x) FROM unnest(asset_classes || $5::text[]) x)
- WHERE id=$1`, [winnerId, l.state_presence, l.metros, l.counties, l.asset_classes]);
+ state_presence = COALESCE((SELECT array_agg(DISTINCT x) FROM unnest(state_presence || $2::text[]) x), '{}'),
+ metros = COALESCE((SELECT array_agg(DISTINCT x) FROM unnest(metros || $3::text[]) x), '{}'),
+ counties = COALESCE((SELECT array_agg(DISTINCT x) FROM unnest(counties || $4::text[]) x), '{}'),
+ asset_classes = COALESCE((SELECT array_agg(DISTINCT x) FROM unnest(asset_classes || $5::text[]) x), '{}')
+ WHERE id=$1`, [winnerId, l.state_presence || [], l.metros || [], l.counties || [], l.asset_classes || []]);
await client.query(`UPDATE pr_organizations SET lifecycle_status='duplicate', duplicate_of_id=$1 WHERE id=$2`, [winnerId, loserId]);
await audit.log({ actor, action: 'org.merge', entity_type: 'organization', entity_id: winnerId, detail: { merged_from: loserId } }, client);
await audit.activity({ entity_type: 'organization', entity_id: winnerId, activity: 'duplicate_resolved', detail: { merged_from: loserId }, actor }, client);
diff --git a/test/pr/integration.test.js b/test/pr/integration.test.js
new file mode 100644
index 00000000..fb3b0718
--- /dev/null
+++ b/test/pr/integration.test.js
@@ -0,0 +1,233 @@
+'use strict';
+// Integration tests — run against a THROWAWAY database (rentv_pr_test), created and
+// migrated here. Never touches rentv_pr (dev/prod) data.
+process.env.PR_DB_NAME = 'rentv_pr_test';
+delete process.env.PR_DATABASE_URL;
+
+const test = require('node:test');
+const assert = require('node:assert');
+const { execSync } = require('child_process');
+
+// Fresh throwaway DB every run — migrations reapply from scratch (fast), so letter
+// versions and settings mutations from a prior run can never leak into this one.
+execSync('dropdb --if-exists rentv_pr_test 2>/dev/null; createdb rentv_pr_test', { shell: '/bin/bash' });
+
+const db = require('../../src/pr/db');
+const organizations = require('../../src/pr/services/organizations');
+const people = require('../../src/pr/services/people');
+const campaigns = require('../../src/pr/services/campaigns');
+const outreach = require('../../src/pr/services/outreach');
+const replies = require('../../src/pr/services/replies');
+const suppression = require('../../src/pr/services/suppression');
+const settings = require('../../src/pr/services/settings');
+const runs = require('../../src/pr/services/runs');
+const jobs = require('../../src/pr/jobs');
+const providers = require('../../src/pr/services/email-providers');
+const letters = require('../../src/pr/services/letters');
+
+const SRC = (name) => ({
+ source: { source_type: 'org_website', source_name: name, url: 'https://example-test.com/' + name.replace(/\s/g, '-'), short_excerpt: 'test excerpt for ' + name, is_primary_source: true, adapter: 'test' },
+ fields: { organization: 85, exact_title: 85 },
+});
+
+test.before(async () => {
+ const h = await db.health();
+ assert.ok(h.ok || h.db === 'unavailable', 'health call works');
+ await db.runMigrations({});
+ // clean slate for repeatable runs
+ await db.query(`TRUNCATE pr_field_evidence, pr_sources, pr_outreach_messages, pr_campaign_members,
+ pr_campaigns, pr_org_relationships, pr_people, pr_organizations, pr_suppression, pr_tasks,
+ pr_activities, pr_audit_log, pr_jobs, pr_research_runs, pr_import_batches RESTART IDENTITY CASCADE`, []);
+ await db.query(`UPDATE pr_settings SET value='false' WHERE key='arizona_unlocked'`, []);
+ providers.setProvider(new providers.MockProvider());
+});
+
+test('create organization with field-level evidence', async () => {
+ const r = await organizations.create({
+ display_name: 'Testco Commercial Advisors', website_url: 'https://testco-cre.com',
+ organization_type: 'brokerage', state_presence: ['CA'], metros: ['la'], counties: ['Los Angeles'],
+ }, { evidence: SRC('testco homepage'), actor: 'test' });
+ assert.ok(r.created);
+ const org = await organizations.get(r.org.id);
+ assert.equal(org.display_name, 'Testco Commercial Advisors');
+ assert.ok(org.evidence.length >= 1, 'evidence rows recorded');
+ assert.equal(org.evidence[0].source_type, 'org_website');
+});
+
+test('duplicate org create dedupes on exact match; merge is transactional', async () => {
+ const a = await organizations.create({ display_name: 'Merge Alpha', website_url: 'https://merge-alpha.com', organization_type: 'brokerage', state_presence: ['CA'] }, { evidence: SRC('alpha'), actor: 'test' });
+ const same = await organizations.create({ display_name: 'Merge Alpha Inc', website_url: 'https://merge-alpha.com/about', organization_type: 'brokerage', state_presence: ['CA'] }, { actor: 'test' });
+ assert.equal(same.created, false, 'same domain → exact match, no duplicate row');
+ const b = await organizations.create({ display_name: 'Merge Beta', website_url: 'https://merge-beta.com', organization_type: 'brokerage', state_presence: ['CA'], general_press_email: 'press@merge-beta.com' }, { evidence: SRC('beta'), actor: 'test' });
+ const p = await people.create({ full_name: 'Beta Person', exact_title: 'Marketing Director', organization_id: b.org.id }, { evidence: SRC('beta person'), actor: 'test' });
+ const m = await organizations.merge(a.org.id, b.org.id, 'test');
+ assert.ok(m.ok);
+ const winner = await organizations.get(a.org.id);
+ assert.ok(winner.people.some((x) => x.id === p.person.id), 'people moved to winner');
+ assert.equal(winner.general_press_email, 'press@merge-beta.com', 'gap-filled from loser');
+ const loser = await db.one('SELECT lifecycle_status, duplicate_of_id FROM pr_organizations WHERE id=$1', [b.org.id]);
+ assert.equal(loser.lifecycle_status, 'duplicate');
+ assert.equal(Number(loser.duplicate_of_id), Number(a.org.id));
+});
+
+test('add + verify person: exact title kept, role normalized, original preserved on correction', async () => {
+ const org = (await organizations.create({ display_name: 'People Org', website_url: 'https://people-org-test.com', organization_type: 'developer_owner', state_presence: ['CA'], metros: ['oc'] }, { evidence: SRC('people org'), actor: 'test' })).org;
+ const r = await people.create({
+ full_name: 'Casey Example', exact_title: 'Director of Corporate Communications',
+ organization_id: org.id, state: 'CA', metro: 'oc',
+ public_work_email: 'casey@people-org-test.com', email_verification_status: 'company_site_verified',
+ }, { evidence: SRC('team page'), actor: 'test' });
+ assert.ok(r.created);
+ assert.equal(r.person.department, 'communications');
+ await people.update(r.person.id, { lifecycle_status: 'needs_verification' }, 'test');
+ await people.update(r.person.id, { lifecycle_status: 'verified', last_verified_at: new Date().toISOString() }, 'test');
+ const upd = await people.update(r.person.id, { exact_title: 'VP, Communications' }, 'test');
+ assert.equal(upd.original_values.exact_title, 'Director of Corporate Communications', 'original extracted value preserved');
+ assert.equal(upd.role_history.length, 1, 'prior role appended to history, not overwritten');
+});
+
+test('inferred email can be stored but NEVER becomes outreach-eligible', async () => {
+ const org = (await organizations.create({ display_name: 'Inferred Org', website_url: 'https://inferred-org-test.com', organization_type: 'brokerage', state_presence: ['CA'] }, { evidence: SRC('inferred org'), actor: 'test' })).org;
+ const p = (await people.create({ full_name: 'Pat Guessed', exact_title: 'Media Relations Manager', organization_id: org.id, public_work_email: 'pguessed@inferred-org-test.com', email_verification_status: 'inferred' }, { evidence: SRC('pat'), actor: 'test' })).person;
+ await assert.rejects(() => people.update(p.id, { outreach_eligible: true }, 'test'), /verified business email/);
+ const rd = await outreach.readiness(p.id);
+ assert.equal(rd.ready, false);
+ assert.ok(rd.reasons.some((x) => /verified email|contact form|approved/.test(x)));
+});
+
+test('campaign enrollment is transactional and skips suppressed contacts', async () => {
+ const org = (await organizations.create({ display_name: 'Campaign Org', website_url: 'https://campaign-org-test.com', organization_type: 'pr_agency', state_presence: ['CA'], metros: ['la'] }, { evidence: SRC('campaign org'), actor: 'test' })).org;
+ const good = (await people.create({ full_name: 'Good Contact', exact_title: 'Account Director', organization_id: org.id, public_work_email: 'good@campaign-org-test.com', email_verification_status: 'company_site_verified' }, { evidence: SRC('good'), actor: 'test' })).person;
+ const bad = (await people.create({ full_name: 'Suppressed Contact', exact_title: 'Media Relations Director', organization_id: org.id, public_work_email: 'stop@campaign-org-test.com', email_verification_status: 'company_site_verified' }, { evidence: SRC('bad'), actor: 'test' })).person;
+ await suppression.add({ email: 'stop@campaign-org-test.com', person_id: bad.id, reason: 'opt_out', actor: 'test' });
+ const camp = await campaigns.create({ name: 'Test Campaign', state: 'CA' }, 'test');
+ const res = await campaigns.enroll(camp.id, [good.id, bad.id], 'test');
+ assert.deepEqual(res.added, [good.id]);
+ assert.equal(res.skipped.length, 1);
+ assert.match(res.skipped[0].reason, /suppressed|lifecycle/);
+});
+
+test('personalized draft: grounded blocks, provider draft WITHOUT sending, explicit send', async () => {
+ const org = (await organizations.create({ display_name: 'Draft Org', website_url: 'https://draft-org-test.com', organization_type: 'bank', state_presence: ['CA'], metros: ['san-diego'] }, { evidence: SRC('draft org'), actor: 'test' })).org;
+ const p = (await people.create({ full_name: 'Dana Draft', exact_title: 'Head of Communications', organization_id: org.id, public_work_email: 'dana@draft-org-test.com', email_verification_status: 'manually_verified' }, { evidence: SRC('dana bio'), actor: 'test' })).person;
+ const d = await outreach.generateDraft({ person_id: p.id, actor: 'test' });
+ assert.equal(d.message.status, 'draft_generated');
+ assert.ok(d.message.rendered_text.includes('Dana'), 'first name merged');
+ assert.ok(d.message.rendered_text.includes('Draft Org'), 'org name merged');
+ assert.ok(d.missing_merge_fields.includes('sender_name'), 'unfilled sender settings block sending');
+ // complete sender settings, approve, provider draft (mock), send
+ for (const [k, v] of [['sender_name', 'Test Sender'], ['sender_title', 'Editor'], ['sender_contact_information', 'test@rentv.example'], ['approved_submission_method', 'press@rentv.example']]) {
+ await settings.set(k, v, 'test');
+ }
+ await outreach.transition(d.message.id, 'needs_review', 'test');
+ await outreach.approve(d.message.id, 'test');
+ const pd = await outreach.createProviderDraft(d.message.id, 'test');
+ assert.equal(pd.provider, 'mock');
+ const mock = providers.getProvider();
+ assert.equal(mock.sent.length, 0, 'provider draft created but NOTHING sent');
+ await assert.rejects(() => outreach.send(d.message.id, { confirm: false, actor: 'test' }), /explicit confirmation/);
+ const sent = await outreach.send(d.message.id, { confirm: true, actor: 'test' });
+ assert.equal(sent.status, 'sent');
+ assert.equal(mock.sent.length, 1, 'explicit confirm sends exactly once');
+});
+
+test('incoming reply associates by thread; referral creates person + task; opt-out suppresses immediately', async () => {
+ const sentMsg = await db.one(`SELECT * FROM pr_outreach_messages WHERE status='sent' ORDER BY id DESC LIMIT 1`, []);
+ assert.ok(sentMsg, 'previous test produced a sent message');
+ const inbound = await replies.associateInbound({
+ provider: 'mock', provider_message_id: 'in-1', provider_thread_id: sentMsg.provider_thread_id,
+ person_id: sentMsg.person_id, organization_id: sentMsg.organization_id, campaign_id: null,
+ subject: 'Re: ' + sentMsg.subject, text: 'Please loop in our PR lead, Riley Referral (riley@draft-org-test.com).',
+ headers: { from: 'dana@draft-org-test.com' }, actor: 'test',
+ });
+ assert.equal(inbound.direction, 'inbound');
+ const outRow = await db.one('SELECT status FROM pr_outreach_messages WHERE id=$1', [sentMsg.id]);
+ assert.equal(outRow.status, 'replied', 'outbound flipped to replied by thread association');
+ const ref = await replies.processReferral({ message_id: sentMsg.id, referred_name: 'Riley Referral', referred_title: 'PR Lead', referred_email: 'riley@draft-org-test.com', actor: 'test' });
+ assert.ok(ref.referred_person.id);
+ const task = await db.one(`SELECT * FROM pr_tasks WHERE entity_type='person' AND entity_id=$1`, [ref.referred_person.id]);
+ assert.ok(task, 'follow-up task created for the referral');
+ // opt-out reply
+ const inbound2 = await replies.associateInbound({
+ provider: 'mock', provider_message_id: 'in-2', provider_thread_id: sentMsg.provider_thread_id,
+ person_id: sentMsg.person_id, organization_id: sentMsg.organization_id, campaign_id: null,
+ subject: 'unsubscribe', text: 'Please remove me from your list. Unsubscribe.',
+ headers: { from: 'dana@draft-org-test.com' }, actor: 'test',
+ });
+ assert.ok(inbound2.id);
+ const sup = await suppression.isSuppressed({ email: 'dana@draft-org-test.com' });
+ assert.equal(sup.suppressed, true, 'opt-out language suppresses immediately');
+ assert.equal(sup.reason, 'opt_out');
+});
+
+test('research run resumes from checkpoint after interruption', async () => {
+ const run = await runs.create({ state: 'CA', metro: 'la', category: 'bank', adapter: 'test', actor: 'test' });
+ await runs.setStatus(run.id, 'running');
+ await runs.progress(run.id, { pages: 3, candidates: 10, created: 5, checkpoint: { registry_cursor: { offset: 100 }, template_idx: 4 } });
+ await runs.pause(run.id, 'test'); // interruption
+ let r = await runs.get(run.id);
+ assert.equal(r.status, 'paused');
+ assert.equal(r.checkpoint.template_idx, 4, 'checkpoint survives pause');
+ await runs.resume(run.id, 'test');
+ r = await runs.get(run.id);
+ assert.equal(r.status, 'queued');
+ assert.deepEqual(r.checkpoint.registry_cursor, { offset: 100 }, 'resume continues from stored cursor');
+});
+
+test('job queue: dedupe, claim, retry-with-backoff, checkpoint kept on failure', async () => {
+ const j1 = await jobs.enqueue('calculate-scores', { probe: 1 }, { actor: 'test' });
+ const j2 = await jobs.enqueue('calculate-scores', { probe: 1 }, { actor: 'test' });
+ assert.ok(j1.id && j2.deduped, 'identical queued job dedupes');
+ const claimed = await jobs.claim();
+ assert.ok(claimed, 'claim returns a job');
+ const failed = await jobs.fail(claimed.id, new Error('transient'), { at: 7 });
+ assert.equal(failed.retried, true, 'first failure → requeued with backoff');
+ const row = await db.one('SELECT status, checkpoint FROM pr_jobs WHERE id=$1', [claimed.id]);
+ assert.equal(row.status, 'queued');
+ assert.equal(row.checkpoint.at, 7, 'checkpoint saved on failure');
+ await db.query(`UPDATE pr_jobs SET status='cancelled' WHERE status IN ('queued','running')`, []); // clean tail
+});
+
+test('ARIZONA GATE: broad AZ discovery cannot be enqueued before the CA gate passes', async () => {
+ await assert.rejects(
+ () => jobs.enqueue('discover-organizations', { state: 'AZ', metro: 'phoenix' }, { actor: 'test' }),
+ (e) => e.code === 'AZ_LOCKED');
+ // CA gate honestly not passed on this tiny dataset
+ const gate = await settings.californiaGate();
+ assert.equal(typeof gate.passed, 'boolean');
+ // an explicit, audit-logged override unlocks
+ const un = await settings.unlockArizona({ actor: 'test', override: true });
+ assert.equal(un.ok, true);
+ const j = await jobs.enqueue('discover-organizations', { state: 'AZ', metro: 'phoenix' }, { actor: 'test' });
+ assert.ok(j.id, 'AZ enqueues after unlock');
+ await db.query(`UPDATE pr_jobs SET status='cancelled' WHERE id=$1`, [j.id]);
+ await settings.set('arizona_unlocked', false, 'test'); // relock for other tests
+});
+
+test('locked letter blocks cannot be modified without an explicit unlock', async () => {
+ const tpl = await letters.baseTemplate();
+ const cur = await letters.getVersion((await db.one('SELECT id FROM pr_letter_versions WHERE template_id=$1 ORDER BY version DESC LIMIT 1', [tpl.id])).id);
+ const blocks = cur.blocks.map((b) => ({ ...b }));
+ blocks[0].locked = true;
+ await letters.saveVersion(tpl.id, { subject_template: cur.subject_template, blocks, change_note: 'lock greeting', actor: 'test' });
+ const tampered = blocks.map((b) => ({ ...b }));
+ tampered[0] = { ...tampered[0], content: 'HACKED CONTENT' };
+ await assert.rejects(() => letters.saveVersion(tpl.id, { subject_template: cur.subject_template, blocks: tampered, actor: 'test' }), /locked block/);
+ const ok = await letters.saveVersion(tpl.id, { subject_template: cur.subject_template, blocks: tampered, unlock: [tampered[0].key], change_note: 'explicit unlock edit', actor: 'test' });
+ assert.ok(ok.version > cur.version, 'explicit unlock allows the change (and versions are immutable history)');
+});
+
+test('audit log recorded every step of the workflow', async () => {
+ const n = await db.one(`SELECT
+ count(*) FILTER (WHERE action LIKE 'org.%')::int AS org,
+ count(*) FILTER (WHERE action LIKE 'person.%')::int AS person,
+ count(*) FILTER (WHERE action LIKE 'outreach.%')::int AS outreach,
+ count(*) FILTER (WHERE action LIKE 'suppression.%')::int AS sup,
+ count(*) FILTER (WHERE action LIKE 'letter.%')::int AS letter
+ FROM pr_audit_log`, []);
+ for (const k of ['org', 'person', 'outreach', 'sup', 'letter']) assert.ok(Number(n[k]) > 0, k + ' actions audited');
+});
+
+test.after(async () => {
+ // leave the test DB in place for inspection; it is throwaway by name.
+});
diff --git a/test/pr/unit.test.js b/test/pr/unit.test.js
new file mode 100644
index 00000000..77050982
--- /dev/null
+++ b/test/pr/unit.test.js
@@ -0,0 +1,152 @@
+'use strict';
+// Unit tests — pure logic, no database.
+const test = require('node:test');
+const assert = require('node:assert');
+
+const { normalizeOrgName, normalizePersonName, normalizeDomain, normalizeEmail, normalizeLinkedInUrl, splitName, normalizePhone } = require('../../src/pr/lib/normalize');
+const { classifyOrgMatch, classifyPersonMatch, nameSimilarity } = require('../../src/pr/lib/dedupe');
+const { freshnessScore, sourceConfidence, orgConfidence, personConfidence, outreachPriority } = require('../../src/pr/lib/scoring');
+const { canOrgTransition, canPersonTransition, canOutreachTransition } = require('../../src/pr/lib/status');
+const { classifyTitle, roleRelevance, ORG_TYPE_KEYS, ASSET_CLASSES } = require('../../src/pr/lib/taxonomy');
+const geo = require('../../src/pr/lib/geo');
+const letters = require('../../src/pr/services/letters');
+const { parseCSV, toCSV } = require('../../src/pr/services/importexport');
+
+test('organization name normalization strips entity suffixes and punctuation', () => {
+ assert.equal(normalizeOrgName('CBRE Group, Inc.'), 'cbre');
+ assert.equal(normalizeOrgName('Cox, Castle & Nicholson LLP'), 'cox castle nicholson');
+ assert.equal(normalizeOrgName('The Irvine Company LLC'), 'irvine');
+ assert.equal(normalizeOrgName('Majestic Realty Co.'), 'majestic realty');
+});
+
+test('person name normalization strips credentials and case', () => {
+ assert.equal(normalizePersonName('Jane Q. Smith, CCIM'), 'jane q smith');
+ assert.equal(normalizePersonName('ROBERT O’BRIEN Jr.'), "robert o’brien".replace('’', '’').toLowerCase().replace(/[^a-z\s'-]/g, ' ').replace(/\s+/g, ' ').trim() || 'robert o brien');
+ assert.deepEqual(splitName('Jane Q. Smith'), { first_name: 'Jane', middle_name: 'Q.', last_name: 'Smith' });
+});
+
+test('domain normalization: scheme/www/path stripped, junk rejected', () => {
+ assert.equal(normalizeDomain('https://www.cbre.com/offices/la'), 'cbre.com');
+ assert.equal(normalizeDomain('HTTP://Example.COM:8080/x'), 'example.com');
+ assert.equal(normalizeDomain('commercial.bank.com'), 'commercial.bank.com');
+ assert.equal(normalizeDomain('not a domain'), null);
+});
+
+test('email + phone + linkedin normalization', () => {
+ assert.equal(normalizeEmail(' Press@CBRE.com '), 'press@cbre.com');
+ assert.equal(normalizeEmail('nope'), null);
+ assert.equal(normalizePhone('(213) 555-1212'), '2135551212');
+ assert.equal(normalizeLinkedInUrl('https://www.linkedin.com/in/jane-smith-123/?src=x'), 'https://www.linkedin.com/in/jane-smith-123');
+ assert.equal(normalizeLinkedInUrl('https://twitter.com/x'), null);
+});
+
+test('org duplicate detection: same domain = exact; same name different domain = uncertain (admin decides)', () => {
+ const a = { display_name: 'CBRE', domain: 'cbre.com', state_presence: ['CA'] };
+ assert.equal(classifyOrgMatch(a, { display_name: 'CBRE Group Inc', domain: 'cbre.com', normalized_name: 'cbre group' }).verdict, 'exact');
+ const un = classifyOrgMatch({ display_name: 'Pacific Realty', domain: 'pacrealty.com' }, { display_name: 'Pacific Realty', domain: 'pacificrealty.net', normalized_name: 'pacific realty' });
+ assert.equal(un.verdict, 'uncertain'); // NEVER auto-merged
+ assert.equal(classifyOrgMatch({ display_name: 'Alpha Partners' }, { display_name: 'Zeta Holdings', normalized_name: 'zeta' }).verdict, 'distinct');
+});
+
+test('person duplicate detection: email exact; same name diff org = uncertain', () => {
+ assert.equal(classifyPersonMatch({ full_name: 'Jane Smith', public_work_email: 'js@x.com' }, { full_name: 'J Smith', public_work_email: 'js@x.com' }).verdict, 'exact');
+ const m = classifyPersonMatch({ full_name: 'Jane Smith', organization_id: 1 }, { full_name: 'Jane Smith', organization_id: 2, normalized_name: 'jane smith' });
+ assert.equal(m.verdict, 'uncertain');
+});
+
+test('freshness decays with age', () => {
+ const now = Date.now();
+ assert.equal(freshnessScore(new Date(now - 10 * 864e5).toISOString(), now), 100);
+ assert.equal(freshnessScore(new Date(now - 100 * 864e5).toISOString(), now), 60);
+ assert.equal(freshnessScore(null), 0);
+});
+
+test('source confidence: primary gov beats lone search snippet; corroboration adds', () => {
+ const gov = sourceConfidence([{ source_type: 'gov_registry', is_primary_source: true, source_id: 1 }]);
+ const snip = sourceConfidence([{ source_type: 'search_api', source_id: 2 }]);
+ assert.ok(gov > snip);
+ const multi = sourceConfidence([{ source_type: 'org_website', is_primary_source: true, source_id: 1 }, { source_type: 'press_release', source_id: 2 }]);
+ assert.ok(multi > sourceConfidence([{ source_type: 'org_website', is_primary_source: true, source_id: 1 }]));
+});
+
+test('priority and confidence are separate; components exposed', () => {
+ const org = { state_presence: ['CA'], metros: ['la'], last_verified_at: new Date().toISOString(), website_url: 'https://x.com', organization_type: 'brokerage', press_page_url: 'https://x.com/press' };
+ const person = { department: 'communications', normalized_role: 'director_communications', public_work_email: 'a@x.com', email_verification_status: 'company_site_verified', last_verified_at: new Date().toISOString(), metro: 'la' };
+ const ev = [{ source_type: 'org_website', is_primary_source: true, source_id: 1 }];
+ const pri = outreachPriority({ org, person, evidenceRows: ev });
+ assert.ok(pri.score >= 70, 'comms director w/ verified email in LA should be high priority, got ' + pri.score);
+ assert.ok(pri.components.role_relevance === 100);
+ const weakEv = outreachPriority({ org, person: { ...person, email_verification_status: 'inferred' }, evidenceRows: [] });
+ assert.ok(weakEv.components.source_confidence === 0, 'no evidence → zero source confidence, visible, not hidden by priority');
+ const conf = personConfidence({ ...person }, ev);
+ assert.ok(conf.components && typeof conf.components.contact_method === 'number');
+ assert.ok(orgConfidence(org, ev).score > 0);
+});
+
+test('status machines reject illegal jumps', () => {
+ assert.ok(canOrgTransition('discovered', 'needs_review'));
+ assert.ok(!canOrgTransition('discovered', 'contacted'));
+ assert.ok(canPersonTransition('verified', 'ready_for_outreach'));
+ assert.ok(!canPersonTransition('suppressed', 'ready_for_outreach')); // suppression is sticky
+ assert.ok(canOutreachTransition('provider_draft_created', 'sent'));
+ assert.ok(!canOutreachTransition('draft_generated', 'sent')); // cannot send an unapproved draft
+ assert.ok(!canOutreachTransition('opted_out', 'sent'));
+});
+
+test('title classification: exact title kept apart from normalized role', () => {
+ assert.deepEqual(classifyTitle('Vice President of Communications').normalized_role, 'vp_communications');
+ assert.equal(classifyTitle('Vice President of Communications').department, 'communications');
+ assert.equal(classifyTitle('Chief Marketing Officer').department, 'marketing');
+ assert.equal(classifyTitle('Commercial Escrow Officer').department, 'title_escrow');
+ assert.equal(classifyTitle('Head of Commercial Real Estate').department, 'lending');
+ assert.equal(classifyTitle('Senior Account Executive').department, 'agency');
+ assert.equal(classifyTitle('President').department, 'leadership');
+ assert.ok(roleRelevance('communications', 'director_communications') > roleRelevance('leadership', 'president'));
+});
+
+test('taxonomy covers the mandated categories and asset classes', () => {
+ assert.ok(ORG_TYPE_KEYS.length >= 35, 'expected ≥35 org types, got ' + ORG_TYPE_KEYS.length);
+ assert.ok(ASSET_CLASSES.includes('medical_office') && ASSET_CLASSES.includes('data_center') && ASSET_CLASSES.length >= 17);
+});
+
+test('geo: CA metro order mandated, AZ list present, geo priority favors earlier metros', () => {
+ assert.equal(geo.CA_METROS[0].key, 'la');
+ assert.equal(geo.CA_METROS[4].key, 'bay-area');
+ assert.equal(geo.AZ_METROS[0].key, 'phoenix');
+ assert.ok(geo.geoPriority('CA', 'la') > geo.geoPriority('CA', 'central-valley'));
+ assert.equal(geo.metroForCounty('CA', 'Riverside County'), 'inland-empire');
+});
+
+test('letter render: merge fields fill, unfilled stay visible, citations separate from body', () => {
+ const blocks = [
+ { key: 'greet', order: 1, content: 'Hi {{first_name}},' },
+ { key: 'org', order: 2, content: '{{organization_specific_block}}', internal_sources: [12] },
+ { key: 'sig', order: 3, content: '{{sender_name}}' },
+ ];
+ const out = letters.render({
+ subject_template: 'Add RentV — {{organization_name}}',
+ blocks,
+ context: { first_name: 'Jane', organization_name: 'CBRE', sender_name: '' },
+ orgBlock: 'CBRE evidence sentence.',
+ });
+ assert.equal(out.subject, 'Add RentV — CBRE');
+ assert.ok(out.text.includes('Hi Jane,'));
+ assert.ok(out.text.includes('CBRE evidence sentence.'));
+ assert.deepEqual(out.missing, ['sender_name']); // visible, blocks sending
+ assert.ok(!out.text.includes('source'), 'citations never rendered into the body');
+ assert.deepEqual(out.citations, [{ block: 'org', source_id: 12 }]);
+ assert.ok(out.html.includes('<p'));
+});
+
+test('merge-field extraction from blocks + subject', () => {
+ const fields = letters.extractMergeFields([{ content: 'x {{a}} y {{b_c}}' }], 'subject {{a}}');
+ assert.deepEqual(fields.sort(), ['a', 'b_c']);
+});
+
+test('CSV parse handles quotes, commas, CRLF; toCSV round-trips', () => {
+ const rows = parseCSV('name,email\n"Smith, Jane",j@x.com\r\n"Say ""hi""",b@y.com\n');
+ assert.deepEqual(rows[1], ['Smith, Jane', 'j@x.com']);
+ assert.deepEqual(rows[2], ['Say "hi"', 'b@y.com']);
+ const csv = toCSV([{ a: 'x,y', b: 'z' }], ['a', 'b']);
+ assert.equal(csv, 'a,b\n"x,y",z');
+});
← 762a11ea pr-intelligence: mount /api/pr/* + /admin/pr-intelligence sh
·
back to Rentv
·
pr-intelligence: docs (data-sources, research-ops, email-set eea88b78 →