This commit is contained in:
+27
-3
@@ -107,6 +107,15 @@ def row_json(row):
|
|||||||
if key in result: result[key] = bool(result[key])
|
if key in result: result[key] = bool(result[key])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def source_config_with_credentials(db, source, config):
|
||||||
|
"""Inject a credential only into the in-process adapter call; never persist/return it."""
|
||||||
|
if source["kind"] != "google_places": return config
|
||||||
|
row = db.execute("SELECT secret_ref FROM source_credentials WHERE source_id=? AND organization_id=? AND provider=? AND key_name=?", (source["id"], source["organization_id"], "google_places", "api_key")).fetchone()
|
||||||
|
if not row or not row["secret_ref"]: return config
|
||||||
|
try: config["_api_key"] = decrypt_provider_secret(row["secret_ref"])
|
||||||
|
except Exception: pass
|
||||||
|
return config
|
||||||
|
|
||||||
class ApiHandler(BaseHTTPRequestHandler):
|
class ApiHandler(BaseHTTPRequestHandler):
|
||||||
server_version = "ProspectPlatform/0.1"
|
server_version = "ProspectPlatform/0.1"
|
||||||
def send_json(self, status, payload, extra_headers=None):
|
def send_json(self, status, payload, extra_headers=None):
|
||||||
@@ -1095,6 +1104,9 @@ class ApiHandler(BaseHTTPRequestHandler):
|
|||||||
bits_outreach=path.split("/")
|
bits_outreach=path.split("/")
|
||||||
if len(bits_outreach)==7 and bits_outreach[:4]==["","api","v1","outreach"] and bits_outreach[4]=="drafts" and bits_outreach[5].isdigit() and bits_outreach[6] in {"approve","send"}: return self.approve_outreach_draft(int(bits_outreach[5]),db,user) if bits_outreach[6]=="approve" else self.send_outreach_draft(int(bits_outreach[5]),db,user)
|
if len(bits_outreach)==7 and bits_outreach[:4]==["","api","v1","outreach"] and bits_outreach[4]=="drafts" and bits_outreach[5].isdigit() and bits_outreach[6] in {"approve","send"}: return self.approve_outreach_draft(int(bits_outreach[5]),db,user) if bits_outreach[6]=="approve" else self.send_outreach_draft(int(bits_outreach[5]),db,user)
|
||||||
if path=="/api/v1/sources":return self.create_source(payload,db,user)
|
if path=="/api/v1/sources":return self.create_source(payload,db,user)
|
||||||
|
if path.startswith("/api/v1/sources/") and path.endswith("/credentials"):
|
||||||
|
bits=path.split("/")
|
||||||
|
if len(bits)==6 and bits[4].isdigit(): return self.save_source_credential(int(bits[4]),payload,db,user)
|
||||||
if path=="/api/v1/discovery":return self.create_scoped_discovery(payload,db,user)
|
if path=="/api/v1/discovery":return self.create_scoped_discovery(payload,db,user)
|
||||||
if path.startswith("/api/v1/discovery-runs/"):
|
if path.startswith("/api/v1/discovery-runs/"):
|
||||||
bits=path.split("/")
|
bits=path.split("/")
|
||||||
@@ -1320,9 +1332,10 @@ class ApiHandler(BaseHTTPRequestHandler):
|
|||||||
except (TypeError,ValueError): config={}
|
except (TypeError,ValueError): config={}
|
||||||
try: policy=json.loads(raw["policy_json"] or "{}")
|
try: policy=json.loads(raw["policy_json"] or "{}")
|
||||||
except (TypeError,ValueError): policy={}
|
except (TypeError,ValueError): policy={}
|
||||||
|
credential_exists = bool(db.execute("SELECT 1 FROM source_credentials WHERE source_id=? AND organization_id=? AND provider=? AND key_name=?", (raw["id"], org, "google_places", "api_key")).fetchone())
|
||||||
item.update({"available": bool(meta.get("available", False)), "optional": bool(meta.get("optional", False)),
|
item.update({"available": bool(meta.get("available", False)), "optional": bool(meta.get("optional", False)),
|
||||||
"configured": bool(meta.get("available", False) and (config.get("csv") or config.get("rows") is not None or raw["approved"])),
|
"configured": bool(meta.get("available", False) and (config.get("csv") or config.get("rows") is not None or raw["approved"])),
|
||||||
"credential_status": "Not required" if not meta.get("requires_credentials") else "Required / not configured",
|
"credential_status": ("Configured" if credential_exists else "Required / not configured") if meta.get("requires_credentials") else "Not required",
|
||||||
"terms_status": "Provided" if policy.get("terms_url") or config.get("terms_url") else "Not reviewed",
|
"terms_status": "Provided" if policy.get("terms_url") or config.get("terms_url") else "Not reviewed",
|
||||||
"owner": policy.get("owner") or config.get("owner") or "Not assigned",
|
"owner": policy.get("owner") or config.get("owner") or "Not assigned",
|
||||||
"rate_limit": policy.get("rate_limit") or config.get("rate_limit") or "Not set"})
|
"rate_limit": policy.get("rate_limit") or config.get("rate_limit") or "Not set"})
|
||||||
@@ -1331,6 +1344,15 @@ class ApiHandler(BaseHTTPRequestHandler):
|
|||||||
return self.send_json(200,{"organization_id":org,"items":items})
|
return self.send_json(200,{"organization_id":org,"items":items})
|
||||||
def list_queries(self,db,org):
|
def list_queries(self,db,org):
|
||||||
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in db.execute("SELECT * FROM discovery_queries WHERE organization_id=? ORDER BY id",(org,))]})
|
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in db.execute("SELECT * FROM discovery_queries WHERE organization_id=? ORDER BY id",(org,))]})
|
||||||
|
def save_source_credential(self, sid, payload, db, user):
|
||||||
|
source=db.execute("SELECT * FROM sources WHERE id=? AND organization_id=?",(sid,user["organization_id"])).fetchone()
|
||||||
|
if not source: return self.send_json(404,{"error":"not_found"})
|
||||||
|
provider=str(payload.get("provider", "")).strip().lower(); key_name=str(payload.get("key_name", "api_key")).strip().lower(); value=payload.get("api_key")
|
||||||
|
if source["kind"] != "google_places" or provider != "google_places" or key_name != "api_key" or not isinstance(value,str) or not (8 <= len(value.strip()) <= 4096): return self.send_json(400,{"error":"invalid_source_credential"})
|
||||||
|
ciphertext=encrypt_provider_secret(value.strip())
|
||||||
|
db.execute("INSERT INTO source_credentials(source_id,organization_id,provider,key_name,secret_ref,metadata_json) VALUES(?,?,?,?,?,?) ON CONFLICT(source_id,provider,key_name) DO UPDATE SET secret_ref=excluded.secret_ref,metadata_json=excluded.metadata_json",(sid,user["organization_id"],provider,key_name,ciphertext,json.dumps({"configured_at":datetime.now(timezone.utc).replace(microsecond=0).isoformat()})))
|
||||||
|
self.audit(db,user,"source.credential.updated",str(sid)); db.commit()
|
||||||
|
return self.send_json(200,{"ok":True,"configured":True,"provider":provider,"key_name":key_name})
|
||||||
def list_source_records(self,db,org,q):
|
def list_source_records(self,db,org,q):
|
||||||
try:
|
try:
|
||||||
limit=int(q.get('page_size',[50])[0]); offset=max(0,int(q.get('offset',[0])[0]))
|
limit=int(q.get('page_size',[50])[0]); offset=max(0,int(q.get('offset',[0])[0]))
|
||||||
@@ -1382,6 +1404,8 @@ class ApiHandler(BaseHTTPRequestHandler):
|
|||||||
# before they are enabled.
|
# before they are enabled.
|
||||||
if not validation.valid and not (source['kind'] == 'manual' and not config):
|
if not validation.valid and not (source['kind'] == 'manual' and not config):
|
||||||
return self.send_json(409,{"error":"source_not_configured","details":validation.errors})
|
return self.send_json(409,{"error":"source_not_configured","details":validation.errors})
|
||||||
|
if source['kind'] == 'google_places' and not db.execute("SELECT 1 FROM source_credentials WHERE source_id=? AND organization_id=? AND provider=? AND key_name=?", (sid,user['organization_id'],'google_places','api_key')).fetchone():
|
||||||
|
return self.send_json(409,{"error":"source_not_configured","details":["Google Places API key is required"]})
|
||||||
db.execute("UPDATE sources SET enabled=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(value,sid));self.audit(db,user,'source.enabled' if value else 'source.disabled',str(sid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM sources WHERE id=?",(sid,)).fetchone()))
|
db.execute("UPDATE sources SET enabled=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(value,sid));self.audit(db,user,'source.enabled' if value else 'source.disabled',str(sid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM sources WHERE id=?",(sid,)).fetchone()))
|
||||||
def create_query(self,payload,db,user):
|
def create_query(self,payload,db,user):
|
||||||
sid=payload.get('source_id');name=str(payload.get('name','')).strip();query=payload.get('query',{})
|
sid=payload.get('source_id');name=str(payload.get('name','')).strip();query=payload.get('query',{})
|
||||||
@@ -1421,7 +1445,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
|||||||
if len(json.dumps(config).encode())>5*1024*1024:return self.send_json(400,{"error":"ingest_limits"})
|
if len(json.dumps(config).encode())>5*1024*1024:return self.send_json(400,{"error":"ingest_limits"})
|
||||||
if isinstance(config.get('rows'),list) and (len(config['rows'])>1000 or any(not isinstance(r,dict) or len(r)>50 or any(len(str(v))>10000 for v in r.values()) for r in config['rows'])):return self.send_json(400,{"error":"ingest_limits"})
|
if isinstance(config.get('rows'),list) and (len(config['rows'])>1000 or any(not isinstance(r,dict) or len(r)>50 or any(len(str(v))>10000 for v in r.values()) for r in config['rows'])):return self.send_json(400,{"error":"ingest_limits"})
|
||||||
if isinstance(config.get('csv'),str) and config['csv'].count('\n')>1001:return self.send_json(400,{"error":"ingest_limits"})
|
if isinstance(config.get('csv'),str) and config['csv'].count('\n')>1001:return self.send_json(400,{"error":"ingest_limits"})
|
||||||
try:page=adapter_for(source['kind']).discover(config)
|
try:page=adapter_for(source['kind']).discover(source_config_with_credentials(db, source, config))
|
||||||
except (ValueError,KeyError) as exc:return self.send_json(400,{"error":"invalid_ingest","detail":str(exc)})
|
except (ValueError,KeyError) as exc:return self.send_json(400,{"error":"invalid_ingest","detail":str(exc)})
|
||||||
inserted=0
|
inserted=0
|
||||||
for record in page.records[:1000]:
|
for record in page.records[:1000]:
|
||||||
@@ -1559,7 +1583,7 @@ def _run_source_discovery(db, job, handler):
|
|||||||
used_today=db.execute("SELECT COUNT(*) FROM source_records WHERE organization_id=? AND source_id=? AND date(created_at)=date('now')",(org,source["id"])).fetchone()[0]
|
used_today=db.execute("SELECT COUNT(*) FROM source_records WHERE organization_id=? AND source_id=? AND date(created_at)=date('now')",(org,source["id"])).fetchone()[0]
|
||||||
if used_today >= daily_limit or per_run_limit <= 0:
|
if used_today >= daily_limit or per_run_limit <= 0:
|
||||||
handler.add_job_event(db,job["id"],org,"source.quota_exceeded",f"Quota reached for {source['kind']}",10,"SOURCE_QUOTA_EXCEEDED"); continue
|
handler.add_job_event(db,job["id"],org,"source.quota_exceeded",f"Quota reached for {source['kind']}",10,"SOURCE_QUOTA_EXCEEDED"); continue
|
||||||
page=adapter_for(source["kind"]).discover(config)
|
page=adapter_for(source["kind"]).discover(source_config_with_credentials(db, source, config))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failures=int(source["consecutive_failures"])+1
|
failures=int(source["consecutive_failures"])+1
|
||||||
db.execute("UPDATE sources SET health_status='unhealthy',consecutive_failures=?,circuit_open=?,last_failure_at=CURRENT_TIMESTAMP,last_error=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(failures,int(circuit_is_open(failures)),str(exc)[:300],source["id"]))
|
db.execute("UPDATE sources SET health_status='unhealthy',consecutive_failures=?,circuit_open=?,last_failure_at=CURRENT_TIMESTAMP,last_error=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(failures,int(circuit_is_open(failures)),str(exc)[:300],source["id"]))
|
||||||
|
|||||||
+27
-1
@@ -310,9 +310,35 @@ class GatedSource(_Base):
|
|||||||
raise RuntimeError("network_adapter_not_configured")
|
raise RuntimeError("network_adapter_not_configured")
|
||||||
|
|
||||||
|
|
||||||
|
class GooglePlacesSource(GatedSource):
|
||||||
|
kind = source_code = "google_places"
|
||||||
|
display_name = "Google Places"
|
||||||
|
available = True
|
||||||
|
|
||||||
|
def validate_config(self, config):
|
||||||
|
result = super().validate_config(config)
|
||||||
|
if not result.valid: return result
|
||||||
|
if not str(config.get("query", "")).strip(): return ValidationResult(False, ["query is required"])
|
||||||
|
return ValidationResult(True)
|
||||||
|
|
||||||
|
def discover(self, config, cursor=None):
|
||||||
|
result = self.validate_config(config)
|
||||||
|
if not result.valid: raise ValueError(result.errors[0])
|
||||||
|
api_key = str(config.get("_api_key", "")).strip()
|
||||||
|
if not api_key: raise ValueError("Google Places API key is not configured")
|
||||||
|
body = {"textQuery": str(config["query"]).strip(), "pageSize": max(1, min(20, int(config.get("max_records", 20))))}
|
||||||
|
if config.get("region_code"): body["regionCode"] = str(config["region_code"]).upper()[:2]
|
||||||
|
request = Request("https://places.googleapis.com/v1/places:searchText", data=json.dumps(body).encode(), method="POST", headers={"Content-Type":"application/json", "X-Goog-Api-Key":api_key, "X-Goog-FieldMask":"places.displayName,places.websiteUri,places.nationalPhoneNumber,places.internationalPhoneNumber,places.formattedAddress,places.googleMapsUri"})
|
||||||
|
with urlopen(request, timeout=12) as response:
|
||||||
|
payload=json.loads(response.read(2*1024*1024).decode("utf-8", "replace"))
|
||||||
|
records=[]
|
||||||
|
for place in payload.get("places", [])[:20]:
|
||||||
|
name=(place.get("displayName") or {}).get("text", "")
|
||||||
|
records.append(normalize_record({"name":name, "website":place.get("websiteUri", ""), "phone":place.get("nationalPhoneNumber") or place.get("internationalPhoneNumber", ""), "location":place.get("formattedAddress", ""), "source_url":place.get("googleMapsUri", "") , "description":"Google Places result"}))
|
||||||
|
return DiscoveryPage(records, metadata={"adapter":self.source_code,"record_count":len(records),"provider":"google_places"})
|
||||||
|
|
||||||
def _gated(code, name):
|
def _gated(code, name):
|
||||||
return type(name.replace(" ", ""), (GatedSource,), {"kind":code, "source_code":code, "display_name":name})
|
return type(name.replace(" ", ""), (GatedSource,), {"kind":code, "source_code":code, "display_name":name})
|
||||||
GooglePlacesSource = _gated("google_places", "Google Places")
|
|
||||||
BingLocalSource = _gated("bing_local", "Bing / approved local API")
|
BingLocalSource = _gated("bing_local", "Bing / approved local API")
|
||||||
ApprovedDirectorySource = _gated("approved_directory", "Approved directory")
|
ApprovedDirectorySource = _gated("approved_directory", "Approved directory")
|
||||||
PermittedSocialSource = _gated("permitted_social", "Permitted social")
|
PermittedSocialSource = _gated("permitted_social", "Permitted social")
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ class SourceAdapterTests(unittest.TestCase):
|
|||||||
catalog = {item['source_code']: item for item in available_adapters()}
|
catalog = {item['source_code']: item for item in available_adapters()}
|
||||||
for code in ('manual', 'csv', 'public_website', 'ct_logs', 'dns', 'rdap'):
|
for code in ('manual', 'csv', 'public_website', 'ct_logs', 'dns', 'rdap'):
|
||||||
self.assertTrue(catalog[code]['available'], code)
|
self.assertTrue(catalog[code]['available'], code)
|
||||||
for code in ('google_places', 'bing_local', 'approved_directory', 'permitted_social'):
|
for code in ('bing_local', 'approved_directory', 'permitted_social'):
|
||||||
self.assertFalse(catalog[code]['available'], code)
|
self.assertFalse(catalog[code]['available'], code)
|
||||||
self.assertTrue(catalog[code]['optional'], code)
|
self.assertTrue(catalog[code]['optional'], code)
|
||||||
|
self.assertTrue(catalog['google_places']['available'])
|
||||||
|
self.assertTrue(catalog['google_places']['optional'])
|
||||||
|
|
||||||
def test_csv_adapter_is_deterministic_and_normalizes(self):
|
def test_csv_adapter_is_deterministic_and_normalizes(self):
|
||||||
src = CsvSource()
|
src = CsvSource()
|
||||||
|
|||||||
+3
-1
@@ -280,7 +280,7 @@
|
|||||||
const sourceText = (source, keys, fallback='Not returned') => { for (const key of keys) if (source?.[key] !== undefined && source[key] !== null && source[key] !== '') return source[key]; return fallback; };
|
const sourceText = (source, keys, fallback='Not returned') => { for (const key of keys) if (source?.[key] !== undefined && source[key] !== null && source[key] !== '') return source[key]; return fallback; };
|
||||||
function sourceMessage(text, error = false) { const el = $('sourcesMessage'); if (el) { el.textContent = text || ''; el.className = `sources-message${error ? ' error' : ''}`; } }
|
function sourceMessage(text, error = false) { const el = $('sourcesMessage'); if (el) { el.textContent = text || ''; el.className = `sources-message${error ? ' error' : ''}`; } }
|
||||||
function renderSourceSelect() { const select = $('discoverySource'); if (select) select.innerHTML = `<option value="">Select a source</option>${sources.filter(source=>!source.optional).map(s => `<option value="${esc(s.id)}" ${sourceState(s)?'':'disabled'}>${esc(sourceLabel(s))} · ${esc(sourceStatus(s))}</option>`).join('')}`; const multi=$('directDiscoverySources'); if(multi) multi.innerHTML=sources.filter(sourceState).map(s=>`<option value="${esc(s.id)}">${esc(sourceLabel(s))} · ${esc(sourceType(s))}</option>`).join('') || '<option value="" disabled>No approved sources enabled</option>'; }
|
function renderSourceSelect() { const select = $('discoverySource'); if (select) select.innerHTML = `<option value="">Select a source</option>${sources.filter(source=>!source.optional).map(s => `<option value="${esc(s.id)}" ${sourceState(s)?'':'disabled'}>${esc(sourceLabel(s))} · ${esc(sourceStatus(s))}</option>`).join('')}`; const multi=$('directDiscoverySources'); if(multi) multi.innerHTML=sources.filter(sourceState).map(s=>`<option value="${esc(s.id)}">${esc(sourceLabel(s))} · ${esc(sourceType(s))}</option>`).join('') || '<option value="" disabled>No approved sources enabled</option>'; }
|
||||||
function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '<div class="source-empty">No registered or available sources returned by the workspace.</div>'; return; } list.innerHTML = sources.map(source => { const policy=sourceJson(source.policy_json||source.policy), quota=sourceJson(source.quota_json||source.quota), config=sourceJson(source.config_json||source.config), status=sourceStatus(source), configured=source.configured ?? (!source.optional && (source.approved || sourceType(source)==='manual'||sourceType(source)==='csv')), available=source.available ?? (!source.optional || Boolean(source.configured)), health=sourceText(source,['health_status','health'],'Not tested'), failures=sourceText(source,['consecutive_failures'],'0'), circuit=source.circuit_open===true||source.circuit_open===1?'Open':'Closed', credential=sourceText(source,['api_credential_status','credential_status'],source.optional?'Required / not configured':'Not required'), terms=sourceText(source,['terms_status','terms_reviewed'],policy.terms_accepted===true?'Accepted':policy.terms_url||config.terms_url?'Provided':'Not reviewed'), owner=sourceText(source,['owner','owner_name'],policy.owner||config.owner||'Not assigned'), rate=sourceText(source,['rate_limit','rate_limit_label'],policy.rate_limit||config.rate_limit||'Not set'), daily=sourceText(source,['daily_quota','daily_limit'],quota.daily_limit||'Not set'), lastHealth=sourceText(source,['last_health_at','last_checked_at','updated_at'],'Not checked'), success=sourceText(source,['last_success_at'],'No successful run'), error=sourceText(source,['last_error','error'],'None recorded'); return `<article class="source-row ${source.optional?'source-optional':''}" data-source-id="${esc(source.id||sourceType(source))}"><div class="source-row-main"><div class="source-title-line"><strong>${esc(sourceLabel(source))}</strong><span class="source-type">${esc(sourceType(source))}</span></div><small>${source.optional?'Optional adapter · configuration-gated':'Registered workspace source'}</small></div><span class="source-status ${status}">${esc(status==='unavailable'?'Unavailable':status)}</span><dl class="source-facts source-registry-facts"><div><dt>Integration</dt><dd>${esc(source.optional?'Optional adapter':'Registered')}</dd></div><div><dt>Configured</dt><dd>${configured?'Yes':'No'}</dd></div><div><dt>Available</dt><dd>${available?'Yes':'No'}</dd></div><div><dt>Enabled</dt><dd>${sourceState(source)?'Yes':'No'}</dd></div><div><dt>API credential</dt><dd>${esc(credential)}</dd></div><div><dt>Terms</dt><dd>${esc(terms)}</dd></div><div><dt>Owner</dt><dd>${esc(owner)}</dd></div><div><dt>Rate limit</dt><dd>${esc(rate)}</dd></div><div><dt>Daily quota</dt><dd>${esc(daily)}</dd></div><div><dt>Last health</dt><dd>${esc(lastHealth)} · ${esc(health)}</dd></div><div><dt>Success / error</dt><dd>${esc(success)}<br>${esc(error)}</dd></div><div><dt>Circuit</dt><dd>${esc(circuit)} · ${esc(failures)} failures</dd></div></dl><div class="source-actions"><button class="button ghost compact" type="button" data-source-action="review" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Review</button><button class="button ghost compact" type="button" data-source-action="test" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Test</button><button class="button ${status === 'enabled' ? 'danger' : 'primary'} compact" type="button" data-source-action="toggle" data-source-id="${esc(source.id||'')}" ${source.optional||!available?'disabled':''}>${status === 'enabled' ? 'Disable' : 'Enable'}</button></div></article>`; }).join(''); }
|
function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '<div class="source-empty">No registered or available sources returned by the workspace.</div>'; return; } list.innerHTML = sources.map(source => { const policy=sourceJson(source.policy_json||source.policy), quota=sourceJson(source.quota_json||source.quota), config=sourceJson(source.config_json||source.config), status=sourceStatus(source), configured=source.configured ?? (!source.optional && (source.approved || sourceType(source)==='manual'||sourceType(source)==='csv')), available=source.available ?? (!source.optional || Boolean(source.configured)), health=sourceText(source,['health_status','health'],'Not tested'), failures=sourceText(source,['consecutive_failures'],'0'), circuit=source.circuit_open===true||source.circuit_open===1?'Open':'Closed', credential=sourceText(source,['api_credential_status','credential_status'],source.optional?'Required / not configured':'Not required'), terms=sourceText(source,['terms_status','terms_reviewed'],policy.terms_accepted===true?'Accepted':policy.terms_url||config.terms_url?'Provided':'Not reviewed'), owner=sourceText(source,['owner','owner_name'],policy.owner||config.owner||'Not assigned'), rate=sourceText(source,['rate_limit','rate_limit_label'],policy.rate_limit||config.rate_limit||'Not set'), daily=sourceText(source,['daily_quota','daily_limit'],quota.daily_limit||'Not set'), lastHealth=sourceText(source,['last_health_at','last_checked_at','updated_at'],'Not checked'), success=sourceText(source,['last_success_at'],'No successful run'), error=sourceText(source,['last_error','error'],'None recorded'); return `<article class="source-row ${source.optional?'source-optional':''}" data-source-id="${esc(source.id||sourceType(source))}"><div class="source-row-main"><div class="source-title-line"><strong>${esc(sourceLabel(source))}</strong><span class="source-type">${esc(sourceType(source))}</span></div><small>${source.optional?'Optional adapter · configuration-gated':'Registered workspace source'}</small></div><span class="source-status ${status}">${esc(status==='unavailable'?'Unavailable':status)}</span><dl class="source-facts source-registry-facts"><div><dt>Integration</dt><dd>${esc(source.optional?'Optional adapter':'Registered')}</dd></div><div><dt>Configured</dt><dd>${configured?'Yes':'No'}</dd></div><div><dt>Available</dt><dd>${available?'Yes':'No'}</dd></div><div><dt>Enabled</dt><dd>${sourceState(source)?'Yes':'No'}</dd></div><div><dt>API credential</dt><dd>${esc(credential)}</dd></div><div><dt>Terms</dt><dd>${esc(terms)}</dd></div><div><dt>Owner</dt><dd>${esc(owner)}</dd></div><div><dt>Rate limit</dt><dd>${esc(rate)}</dd></div><div><dt>Daily quota</dt><dd>${esc(daily)}</dd></div><div><dt>Last health</dt><dd>${esc(lastHealth)} · ${esc(health)}</dd></div><div><dt>Success / error</dt><dd>${esc(success)}<br>${esc(error)}</dd></div><div><dt>Circuit</dt><dd>${esc(circuit)} · ${esc(failures)} failures</dd></div></dl><div class="source-actions"><button class="button ghost compact" type="button" data-source-action="review" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Review</button><button class="button ghost compact" type="button" data-source-action="test" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Test</button><button class="button ${status === 'enabled' ? 'danger' : 'primary'} compact" type="button" data-source-action="toggle" data-source-id="${esc(source.id||'')}" ${!available?'disabled':''}>${status === 'enabled' ? 'Disable' : 'Enable'}</button></div></article>`; }).join(''); }
|
||||||
function renderSourceRecords(items) { const list = $('sourceRecordsList'); if (!items.length) { list.innerHTML = '<div class="source-empty">No source records returned by the workspace.</div>'; return; } list.innerHTML = `<div class="source-record-table"><table><thead><tr><th>Record</th><th>Source</th><th>Status</th><th>Observed</th></tr></thead><tbody>${items.slice(0,25).map(record => `<tr><td>${esc(record.name || record.title || record.external_id || record.id || 'Unnamed record')}</td><td>${esc(record.source_name || record.source || 'Unknown source')}</td><td><span class="status">${esc(record.status || 'Pending')}</span></td><td>${esc(record.observed_at || record.created_at || 'Time unavailable')}</td></tr>`).join('')}</tbody></table></div>`; }
|
function renderSourceRecords(items) { const list = $('sourceRecordsList'); if (!items.length) { list.innerHTML = '<div class="source-empty">No source records returned by the workspace.</div>'; return; } list.innerHTML = `<div class="source-record-table"><table><thead><tr><th>Record</th><th>Source</th><th>Status</th><th>Observed</th></tr></thead><tbody>${items.slice(0,25).map(record => `<tr><td>${esc(record.name || record.title || record.external_id || record.id || 'Unnamed record')}</td><td>${esc(record.source_name || record.source || 'Unknown source')}</td><td><span class="status">${esc(record.status || 'Pending')}</span></td><td>${esc(record.observed_at || record.created_at || 'Time unavailable')}</td></tr>`).join('')}</tbody></table></div>`; }
|
||||||
async function loadSources() { sourceMessage('Loading sources…'); $('sourcesList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source registry…</div>'; $('sourceRecordsList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source records…</div>'; try { const [sourcePayload, adapterPayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/sources/adapters'), jsonRequest('/api/v1/source-records?page_size=25')]); const configured=sourceItems(sourcePayload), registeredCodes=new Set(configured.map(sourceType)); const optional=sourceItems(adapterPayload).filter(adapter=>!registeredCodes.has(adapter.source_code)).map(adapter=>({...adapter,source_code:adapter.source_code,display_name:adapter.display_name,kind:adapter.source_code,optional:Boolean(adapter.optional),configured:false,available:Boolean(adapter.available),enabled:false})); sources=[...configured,...optional]; renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled. Configure and enable a ready source to begin discovery.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } }
|
async function loadSources() { sourceMessage('Loading sources…'); $('sourcesList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source registry…</div>'; $('sourceRecordsList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source records…</div>'; try { const [sourcePayload, adapterPayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/sources/adapters'), jsonRequest('/api/v1/source-records?page_size=25')]); const configured=sourceItems(sourcePayload), registeredCodes=new Set(configured.map(sourceType)); const optional=sourceItems(adapterPayload).filter(adapter=>!registeredCodes.has(adapter.source_code)).map(adapter=>({...adapter,source_code:adapter.source_code,display_name:adapter.display_name,kind:adapter.source_code,optional:Boolean(adapter.optional),configured:false,available:Boolean(adapter.available),enabled:false})); sources=[...configured,...optional]; renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled. Configure and enable a ready source to begin discovery.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } }
|
||||||
async function saveSource(event) { event.preventDefault(); const form = event.currentTarget, fields = Object.fromEntries(new FormData(form).entries()); if (fields.source_type === 'csv' && !fields.csv_content.trim()) { message('sourceFormMessage', 'CSV content is required for a CSV source.', true); return; } const config = {url:fields.url, terms_url:fields.terms_url, owner:fields.owner, rate_limit:fields.rate_limit}; if (fields.source_type === 'csv') config.csv = fields.csv_content; else config.rows = []; try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:fields.name, kind:fields.source_type, config, enabled:false})}); message('sourceFormMessage', 'Source saved. It remains disabled until explicitly enabled.'); form.reset(); $('sourceCsvField').hidden = true; await loadSources(); } catch (error) { if (error.message !== 'unauthorized') message('sourceFormMessage', error.message || 'Unable to save source.', true); } }
|
async function saveSource(event) { event.preventDefault(); const form = event.currentTarget, fields = Object.fromEntries(new FormData(form).entries()); if (fields.source_type === 'csv' && !fields.csv_content.trim()) { message('sourceFormMessage', 'CSV content is required for a CSV source.', true); return; } const config = {url:fields.url, terms_url:fields.terms_url, owner:fields.owner, rate_limit:fields.rate_limit}; if (fields.source_type === 'csv') config.csv = fields.csv_content; else config.rows = []; try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:fields.name, kind:fields.source_type, config, enabled:false})}); message('sourceFormMessage', 'Source saved. It remains disabled until explicitly enabled.'); form.reset(); $('sourceCsvField').hidden = true; await loadSources(); } catch (error) { if (error.message !== 'unauthorized') message('sourceFormMessage', error.message || 'Unable to save source.', true); } }
|
||||||
@@ -338,6 +338,8 @@ $('aiProviderForm').addEventListener('submit',saveAiProviderSettings);$('aiProvi
|
|||||||
const copy = sourceSetup.querySelector('.source-setup-copy');
|
const copy = sourceSetup.querySelector('.source-setup-copy');
|
||||||
if (badge) badge.textContent = 'Public and operator-controlled sources';
|
if (badge) badge.textContent = 'Public and operator-controlled sources';
|
||||||
if (copy) copy.textContent = 'Register bounded public sources or operator-controlled imports. New sources start disabled and must be tested before use.';
|
if (copy) copy.textContent = 'Register bounded public sources or operator-controlled imports. New sources start disabled and must be tested before use.';
|
||||||
|
sourceSetup.insertAdjacentHTML('afterend', `<article class="panel source-config-panel google-places-panel"><div class="panel-heading"><div><p class="eyebrow">OPTIONAL PROVIDER</p><h3>Google Places discovery</h3></div><span class="small-label">Secure key storage</span></div><p class="muted">Add a Google Places API key here. The key is sent directly to the server, encrypted, and never displayed again.</p><form id="googlePlacesForm" class="crm-form"><label>Search query<input name="query" required placeholder="e.g. plumbers in Cape Town"></label><label>Region code<input name="region_code" value="ZA" maxlength="2"></label><label>Google API key<input name="api_key" type="password" autocomplete="new-password" required placeholder="Paste your key"></label><label class="checkbox-line"><input name="approved" type="checkbox" required> I have enabled Places API, billing, and accepted Google's terms.</label><p id="googlePlacesMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Save key and register source</button></form></article>`);
|
||||||
|
$('googlePlacesForm')?.addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, fields=Object.fromEntries(new FormData(form).entries()); const msg=$('googlePlacesMessage'); msg.textContent='Saving securely…'; msg.className='form-message'; try { const created=await jsonRequest('/api/v1/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'Google Places',kind:'google_places',config:{approved:true,public_access:true,terms_accepted:true,credential_ref:'google_places_api_key',rate_limit:1,query:fields.query.trim(),region_code:(fields.region_code||'ZA').toUpperCase()}})}); await jsonRequest(`/api/v1/sources/${encodeURIComponent(created.id)}/credentials`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({provider:'google_places',key_name:'api_key',api_key:fields.api_key})}); msg.textContent='Google Places is registered and the key is encrypted. Test it, then enable the source.'; form.reset(); form.querySelector('[name="region_code"]').value='ZA'; await loadSources(); } catch(error) { msg.textContent=error.message||'Unable to save Google Places settings.'; msg.className='form-message error'; } });
|
||||||
}
|
}
|
||||||
async function configureSourceFromUi(source, button) {
|
async function configureSourceFromUi(source, button) {
|
||||||
const type = sourceType(source);
|
const type = sourceType(source);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"version": "phase-22",
|
"version": "phase-23",
|
||||||
"entrypoints": [
|
"entrypoints": [
|
||||||
"config.js",
|
"config.js",
|
||||||
"app.js",
|
"app.js",
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
"healthz"
|
"healthz"
|
||||||
],
|
],
|
||||||
"integrity": {
|
"integrity": {
|
||||||
"config.js": "sha256-734a7d93ee125a12ba355206e4e789c6a8e942f0955ad3abcaf604119f1b8b63",
|
"config.js": "sha256-a6bfa48e9656dfcfdd9a8f03492399bff25411753e1db248817443f42668c5d4",
|
||||||
"app.js": "sha256-9073461507ae2e96eb1d62c62d0cc1103bbc427c17f7f42ae7c6f2c52528a87b",
|
"app.js": "sha256-da048730cea4ef1409d180b6cb18f29fa06454e3c8b9be8e140d42ab6b14f171",
|
||||||
"styles.css": "sha256-a9fd194b001c0de98775bcf5fbba5da676ccfdd607a889eae84bd30a54c15e08",
|
"styles.css": "sha256-a9fd194b001c0de98775bcf5fbba5da676ccfdd607a889eae84bd30a54c15e08",
|
||||||
"index.html": "sha256-9f6838c02890eff9b83e32177af01b56c0423758edf9411bc8f196cb61510b5e",
|
"index.html": "sha256-82f92ce6dc4822346e876b341f1cbb2839e9c454d414af673fdc8047fc2fb009",
|
||||||
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
|
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
|
||||||
"error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf",
|
"error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf",
|
||||||
"healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22"
|
"healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
/* Public, non-secret runtime configuration. Replace this file at deploy time if needed. */
|
/* Public, non-secret runtime configuration. Replace this file at deploy time if needed. */
|
||||||
window.__PROSPECT_CONFIG__ = Object.freeze({
|
window.__PROSPECT_CONFIG__ = Object.freeze({
|
||||||
apiBase: '',
|
apiBase: '',
|
||||||
assetVersion: 'phase-22'
|
assetVersion: 'phase-23'
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-3
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>ProspectOS · Pipeline intelligence</title>
|
<title>ProspectOS · Pipeline intelligence</title>
|
||||||
<meta name="description" content="Prospect discovery and review dashboard">
|
<meta name="description" content="Prospect discovery and review dashboard">
|
||||||
<link rel="stylesheet" href="styles.css?v=phase-22">
|
<link rel="stylesheet" href="styles.css?v=phase-23">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section class="login-screen" id="loginScreen" aria-labelledby="loginTitle">
|
<section class="login-screen" id="loginScreen" aria-labelledby="loginTitle">
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
<div class="merge-dialog" id="mergeDialog" hidden role="dialog" aria-modal="true" aria-labelledby="mergeDialogTitle">
|
<div class="merge-dialog" id="mergeDialog" hidden role="dialog" aria-modal="true" aria-labelledby="mergeDialogTitle">
|
||||||
<div class="merge-dialog-card"><div class="panel-heading"><div><p class="eyebrow">REVIEW REQUIRED</p><h2 id="mergeDialogTitle">Confirm merge</h2></div><button class="icon-button" id="cancelMergeBtn" type="button" aria-label="Close merge confirmation">×</button></div><p id="mergeDialogCopy"></p><div class="merge-warning"><strong>This action is reversible.</strong> The merge will be recorded in history and can be reversed later.</div><p id="mergeDialogMessage" class="form-message" role="alert" aria-live="polite"></p><div class="merge-dialog-actions"><button class="button ghost" id="cancelMergeBtnSecondary" type="button">Cancel</button><button class="button primary" id="confirmMergeBtn" type="button">Confirm merge</button></div></div>
|
<div class="merge-dialog-card"><div class="panel-heading"><div><p class="eyebrow">REVIEW REQUIRED</p><h2 id="mergeDialogTitle">Confirm merge</h2></div><button class="icon-button" id="cancelMergeBtn" type="button" aria-label="Close merge confirmation">×</button></div><p id="mergeDialogCopy"></p><div class="merge-warning"><strong>This action is reversible.</strong> The merge will be recorded in history and can be reversed later.</div><p id="mergeDialogMessage" class="form-message" role="alert" aria-live="polite"></p><div class="merge-dialog-actions"><button class="button ghost" id="cancelMergeBtnSecondary" type="button">Cancel</button><button class="button primary" id="confirmMergeBtn" type="button">Confirm merge</button></div></div>
|
||||||
</div>
|
</div>
|
||||||
<script src="config.js?v=phase-22"></script>
|
<script src="config.js?v=phase-23"></script>
|
||||||
<script src="app.js?v=phase-22"></script>
|
<script src="app.js?v=phase-23"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user