add crm pipeline and suppression center
This commit is contained in:
@@ -154,6 +154,34 @@ Dashboard counts are clickable only when their scope and query semantics are cle
|
||||
|
||||
The Phase 11 slice now provides tenant-scoped saved-filter storage/listing, a bounded review-queue read, clickable dashboard filter metadata, and a bounded bulk review route (`POST /api/v1/businesses/bulk-review`) for verify/reject/assign. The current implementation is still pilot-grade: saved-filter update/delete routes are not wired, the queue does not yet expose full matching-set counts or filter snapshots, dashboard click metadata is not a complete predicate contract, bulk actions lack preview/idempotency/per-record outcomes, and audit coverage is aggregate for bulk operations. Production work requires those hardening items plus regression tests for suppression precedence, merge eligibility, cross-tenant IDs, stale counts, and partial bulk failure.
|
||||
|
||||
## Phase 12 CRM pipeline, interactions, outcomes, reporting, and suppression center
|
||||
|
||||
Phase 12 adds the CRM coordination contract around a tenant-scoped pipeline, an append-oriented interaction timeline, normalized interaction outcomes, bounded reporting, and a suppression center. These features are review and record-keeping tools; they do **not** turn the platform into an outreach system. Every read, write, export, report, and background operation must carry the authenticated `organization_id` scope, and a child ID, report ID, filter, or aggregate count is never authorization.
|
||||
|
||||
### Pipeline state and transition rules
|
||||
|
||||
The canonical lifecycle is `new` → `contacted` → `qualified` → `proposal` → `negotiation` → `won` or `lost`. The API exposes these configured stages; any future paused/disqualified state must be explicitly added to the tenant's stage configuration and may be reopened only by an authorized human with a reason. A transition must name the target state, actor, timestamp, and reason/source; the API validates transitions server-side and records the before/after state in the audit trail. Repeating the current state is idempotent, not a new transition. Direct jumps, client-supplied history, edits to historical transitions, and transitions for merged/inactive records are rejected. Reopening `lost`, `paused`, or `disqualified` creates a new transition and does not rewrite history.
|
||||
|
||||
An interaction may suggest a state change, but it never changes pipeline state implicitly. A state change and its related interaction/outcome are separate auditable events, and a failed or partial write must not leave a fabricated outcome. `won`/`lost` require an explicit outcome and reason; `won` is not proof of payment or fulfillment. Suppression/do-not-contact overrides every pipeline state and makes contact eligibility false.
|
||||
|
||||
### Interactions and outcome taxonomy
|
||||
|
||||
Interactions are append-only, tenant-scoped records of an operator-observed event. The record should retain the business/contact reference when known, channel (`note`, `phone`, `email`, `meeting`, or `other`), occurred time, actor, bounded redacted summary, provenance, and correlation/idempotency key. The current product does not send or validate communication: an interaction records what an operator says happened, not what the platform performed.
|
||||
|
||||
Outcomes are normalized and mutually explicit: `connected`, `no_answer`, `left_message`, `meeting_booked`, `meeting_held`, `qualified`, `disqualified`, `won`, `lost`, and `other`. `other` is an explicit catch-all, not proof of success or failure; missing/unknown data must not be silently converted to a negative outcome. `disqualified` and `lost` require an explicit reason under the configured workflow. `do_not_contact` is a separate safety state, immediately creates or confirms tenant suppression, and cannot be downgraded by a later positive outcome. Free-text notes supplement but do not replace the taxonomy; corrections append a new record rather than mutating history.
|
||||
|
||||
### Reporting, audit, tenant scope, and retention
|
||||
|
||||
Reports use an explicit `as_of` time, tenant scope, timezone, date interval, and inclusion policy. Pipeline reports count the latest effective state per active business; interaction reports count events by `occurred_at` (not ingestion time); outcome reports count normalized outcomes and may include a separate `unknown` bucket. Suppressed, merged, deleted, and inactive records must be labeled and excluded from contact-eligible totals; they must not silently disappear from safety/audit counts. Page counts, matching-set counts, and distinct-business counts are different metrics and must be named. Late-arriving or corrected interactions preserve original and corrected timestamps and are never double-counted without an explicit correction policy.
|
||||
|
||||
Audit events cover pipeline transitions, interaction/outcome creation or correction, suppression changes, report/export requests, and report results. Store tenant, actor, target, action, timestamps, safe reason, bounded filter/as-of snapshot or hash, policy/version, correlation/idempotency ID, and per-item outcomes where applicable. Redact full contact values and free text unless required for the approved purpose. Retain CRM records, suppression decisions, interaction provenance, report snapshots, and audit events according to the approved tenant/data-retention schedule; deletion or legal-hold behavior must be explicit and auditable. A report cache is tenant-keyed, bounded, and labeled with its `as_of`/freshness; it is never a live authorization decision.
|
||||
|
||||
### Suppression center and remaining outreach limitations
|
||||
|
||||
Suppression is a tenant-scoped deny list for email, domain, phone, and other approved identifiers. Matching is normalized server-side and must run before persistence, display, export, report inclusion as eligible, queueing, or any future action. Suppression wins over pipeline state, outcome, score, verification, cached data, and operator intent. The center must show the source, reason, actor, created/updated time, scope, and effective status; removal or expiry requires explicit authorization, reason, audit, and re-evaluation. Existing records remain visible as **Do not contact** and are not silently deleted. No Phase 12 route may send email/SMS, probe SMTP, validate an address by message, create a campaign, schedule follow-up delivery, or imply consent/deliverability. Any future outreach requires separate product, legal, security, and operational approval and must remain disabled by default.
|
||||
|
||||
Phase 12 remains pilot-grade until transition validation, immutable interaction/outcome history, suppression precedence, report definitions/timezones, retention/deletion jobs, export controls, idempotent writes, and cross-tenant regression tests are exercised end to end. The current Compose stack still has no durable CRM worker, scheduler, delivery provider, or outreach capability.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
|
||||
@@ -162,6 +162,20 @@ Clickable dashboard counts must link to the exact tenant-scoped predicate that p
|
||||
|
||||
The current Phase 11 slice exposes `GET /api/v1/saved-filters`, `POST /api/v1/saved-filters`, `GET /api/v1/review-queue`, and `POST /api/v1/businesses/bulk-review`. Saved filters are durable and bounded, the queue is capped at 100 rows per request, and bulk verify/reject/assign accepts at most 100 explicit IDs. The slice remains pilot-grade: update/delete saved-filter handlers are not routed, review-queue results do not yet expose a full matching-set count or immutable filter snapshot, dashboard clickable-filter metadata is not a complete predicate, bulk execution has no preview/idempotency/per-record outcome contract, and bulk audit is one aggregate event. Do not infer stronger guarantees from the existing list filters.
|
||||
|
||||
## Phase 12 CRM API contract
|
||||
|
||||
Phase 12 introduces tenant-scoped CRM records for pipeline state, append-only interactions, normalized outcomes, bounded reports, and a suppression center. All routes must use the authenticated session's `organization_id`; a business, interaction, outcome, report, export, suppression, cursor, or filter ID from another tenant behaves as not found. The server—not the web client—enforces role permissions, state transitions, suppression, batch/report limits, and redaction.
|
||||
|
||||
Pipeline transitions use `new` → `contacted` → `qualified` → `proposal` → `negotiation` → `won`/`lost`. Any paused/disqualified state must be explicitly configured before use and requires a reasoned, authorized reopen. The API accepts only policy-approved transitions, rejects direct jumps and changes to merged/inactive records, and appends actor, before/after state, reason, timestamp, and correlation/idempotency metadata. Same-state retries are idempotent. Reopening a terminal-for-now state creates a new event; it never edits history. Interactions do not implicitly advance the pipeline. `won` and `lost` require an explicit outcome/reason.
|
||||
|
||||
Interaction records contain a bounded safe summary, channel, occurred/recorded timestamps, actor, business/contact reference, provenance, and idempotency lineage. Outcomes are normalized to `connected`, `no_answer`, `left_message`, `meeting_booked`, `meeting_held`, `qualified`, `disqualified`, `won`, `lost`, or `other`. `other` is an explicit catch-all, not proof of success or failure; missing data must not be silently assigned a negative outcome. Corrections append a superseding event and preserve the original. `do_not_contact` is safety-critical and cannot be overridden by score, stage, a later outcome, or a client payload.
|
||||
|
||||
Reports must require bounded date ranges and page/row limits and expose their tenant, timezone, `as_of`, freshness, filter snapshot, and semantics. State reports use the latest effective state per active business; interaction/outcome reports use `occurred_at`; counts distinguish events from distinct businesses and page counts from matching-set counts. Suppressed, merged, inactive, and unknown records are labeled and never counted as contact-eligible. Report/export requests and results are audited, report caches are tenant-keyed, and report data must not become an authorization shortcut.
|
||||
|
||||
Suppression endpoints accept only approved normalized identifier kinds and record source, reason, actor, scope, and timestamps. Matching occurs before writes, responses, exports, reports, caches, and queues. Suppressed contacts remain visible as `suppressed`/`do_not_contact` for safety review; deletion or unsuppression requires an authorized, reasoned, audited operation and does not retroactively rewrite interaction history. No endpoint sends messages, probes SMTP, performs validation mail, creates campaigns, or schedules delivery. Outreach remains disabled and requires a separate approved product/security/legal design.
|
||||
|
||||
Every CRM mutation and report/export operation emits an audit record with tenant, actor, action, target, before/after or bounded result, policy/version, timestamps, correlation/idempotency ID, and safe reason. Audit and retention reads use the same organization predicate. CRM records, contact references, suppression decisions, report snapshots, and audit details require explicit retention classes, deletion/legal-hold semantics, and redacted logs. These are the Phase 12 contract; production readiness additionally requires durable migrations, worker/retry behavior, report reproducibility, export authorization, retention jobs, and transition/outcome/suppression/cross-tenant tests.
|
||||
|
||||
## Remaining limitations and production migration work
|
||||
|
||||
SQLite is a pilot store with an MVP job/event schema but no production migration runner, durable queue, scheduler, worker lease/recovery, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Website scanning remains subject to pilot limits: no production egress proxy/isolation, distributed crawl coordinator, hardened resolver, or compliance-grade scan-history retention. Production work must add SSRF/DNS-rebinding/redirect-chain tests, egress policy, authenticated history/cache isolation, budget/abuse enforcement, durable result retention/deletion, observability, reviewed content/robots/terms policy, and the Phase 7 PSL/DNS/availability controls. Redis/Celery/Postgres remain future options, not implemented dependencies.
|
||||
|
||||
+193
-12
@@ -58,6 +58,17 @@ def connect(db_path: str) -> sqlite3.Connection:
|
||||
for col, definition in (("verified", "INTEGER NOT NULL DEFAULT 0"), ("verified_at", "TEXT"), ("updated_at", "TEXT"), ("province", "TEXT NOT NULL DEFAULT ''"), ("city", "TEXT NOT NULL DEFAULT ''"), ("suburb", "TEXT NOT NULL DEFAULT ''"), ("merge_status", "TEXT NOT NULL DEFAULT 'active'"), ("merged_into_id", "INTEGER"), ("review_status", "TEXT NOT NULL DEFAULT 'pending'"), ("assigned_to", "TEXT NOT NULL DEFAULT ''"), ("review_metadata_json", "TEXT NOT NULL DEFAULT '{}'")):
|
||||
if col not in cols: db.execute(f"ALTER TABLE businesses ADD COLUMN {col} {definition}")
|
||||
db.execute("UPDATE businesses SET updated_at=COALESCE(updated_at,created_at) WHERE updated_at IS NULL")
|
||||
# Phase 12 is additive-safe for databases created before CRM metadata existed.
|
||||
for table, additions in {
|
||||
"pipeline_entries": (("notes", "TEXT NOT NULL DEFAULT ''"), ("next_action", "TEXT NOT NULL DEFAULT ''"), ("follow_up_at", "TEXT"), ("actor_user_id", "INTEGER"), ("idempotency_key", "TEXT"), ("version", "INTEGER NOT NULL DEFAULT 1")),
|
||||
"interactions": (("outcome", "TEXT NOT NULL DEFAULT 'other'"), ("notes", "TEXT NOT NULL DEFAULT ''"), ("next_action", "TEXT NOT NULL DEFAULT ''"), ("follow_up_at", "TEXT"), ("actor_user_id", "INTEGER"), ("idempotency_key", "TEXT")),
|
||||
"suppressions": (("active", "INTEGER NOT NULL DEFAULT 1"), ("updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"), ("actor_user_id", "INTEGER")),
|
||||
}.items():
|
||||
existing = {r[1] for r in db.execute(f"PRAGMA table_info({table})")}
|
||||
for col, definition in additions:
|
||||
if col not in existing: db.execute(f"ALTER TABLE {table} ADD COLUMN {col} {definition}")
|
||||
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_pipeline_idempotency ON pipeline_entries(organization_id,idempotency_key) WHERE idempotency_key IS NOT NULL AND idempotency_key <> ''")
|
||||
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_interaction_idempotency ON interactions(organization_id,idempotency_key) WHERE idempotency_key IS NOT NULL AND idempotency_key <> ''")
|
||||
db.execute("INSERT OR IGNORE INTO organizations (id,name) VALUES (?,?)", (ORGANIZATION_ID, "Demo organization"))
|
||||
for organization in db.execute("SELECT id FROM organizations").fetchall():
|
||||
for rule in DEFAULT_RULES:
|
||||
@@ -106,8 +117,8 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
db.execute("INSERT INTO audit_log (organization_id,user_id,action,details) VALUES (?,?,?,?)",(user["organization_id"],user["id"],action,details))
|
||||
def business(self, db, ident, org): return db.execute("SELECT * FROM businesses WHERE id=? AND organization_id=?",(ident,org)).fetchone()
|
||||
def nested(self, db, bid, org):
|
||||
result={"contacts":[],"domains":[],"websites":[],"evidence":[],"pipeline":[],"notes":[]}
|
||||
tables={"contacts":"contacts","domains":"domains","websites":"websites","evidence":"evidence","pipeline":"pipeline_entries","notes":"notes"}
|
||||
result={"contacts":[],"domains":[],"websites":[],"evidence":[],"pipeline":[],"interactions":[],"notes":[]}
|
||||
tables={"contacts":"contacts","domains":"domains","websites":"websites","evidence":"evidence","pipeline":"pipeline_entries","interactions":"interactions","notes":"notes"}
|
||||
for key, table in tables.items():
|
||||
result[key]=[row_json(r) for r in db.execute(f"SELECT * FROM {table} WHERE business_id=? AND organization_id=? ORDER BY id",(bid,org))]
|
||||
return result
|
||||
@@ -181,7 +192,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _review_item(self, row, db, org):
|
||||
item = row_json(row)
|
||||
suppressed = is_suppressed(dict(row), [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?", (org,))])
|
||||
suppressed = is_suppressed(dict(row), [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1", (org,))])
|
||||
merged = row["merge_status"] == "merged"
|
||||
item.update({"suppressed": bool(suppressed), "merged": bool(merged), "outreach_eligible": not suppressed and not merged and row["review_status"] not in ("rejected",), "review_flags": [x for x, yes in (("suppressed", suppressed), ("merged", merged), ("rejected", row["review_status"] == "rejected")) if yes]})
|
||||
return item
|
||||
@@ -222,6 +233,69 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
rows = db.execute("SELECT b.* FROM businesses b WHERE " + " AND ".join(where) + " ORDER BY b.score DESC,b.updated_at DESC,b.id DESC LIMIT ? OFFSET ?", params + [limit + 1, offset]).fetchall()
|
||||
return self.send_json(200, {"organization_id": org, "items": [self._review_item(r, db, org) for r in rows[:limit]], "limit": limit, "offset": offset, "has_more": len(rows) > limit})
|
||||
|
||||
OUTCOMES = {"connected", "no_answer", "left_message", "meeting_booked", "meeting_held", "qualified", "disqualified", "won", "lost", "other"}
|
||||
STAGES = {"new", "contacted", "qualified", "proposal", "negotiation", "won", "lost"}
|
||||
|
||||
def _crm_suppressed(self, db, org, business):
|
||||
return is_suppressed(dict(business), [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1", (org,))])
|
||||
|
||||
def _date_where(self, query, column, params):
|
||||
start = (query.get("from") or query.get("start") or [""])[0]
|
||||
end = (query.get("to") or query.get("end") or [""])[0]
|
||||
for value in (start, end):
|
||||
if value and (len(value) > 30 or not re.match(r"^\\d{4}-\\d{2}-\\d{2}(?:T[^ ]*)?$", value)):
|
||||
raise ValueError("invalid_date")
|
||||
if start and end:
|
||||
try:
|
||||
left=datetime.fromisoformat(start.replace("Z","+00:00")).date(); right=datetime.fromisoformat(end.replace("Z","+00:00")).date()
|
||||
if (right-left).days > 366: raise ValueError("date_range_too_large")
|
||||
except ValueError as exc:
|
||||
if str(exc) == "date_range_too_large": raise
|
||||
raise ValueError("invalid_date_range")
|
||||
if start: params.append(start); clause = f"{column}>=?"
|
||||
else: clause = ""
|
||||
if end: params.append(end); clause += (" AND " if clause else "") + f"{column}<=?"
|
||||
return clause
|
||||
|
||||
def list_pipeline(self, db, org, query):
|
||||
params=[org]; where=["p.organization_id=?"]
|
||||
bid=(query.get("business_id") or [""])[0]
|
||||
if bid.isdigit(): where.append("p.business_id=?"); params.append(int(bid))
|
||||
stage=(query.get("stage") or [""])[0]
|
||||
if stage: where.append("p.stage=?"); params.append(stage)
|
||||
rows=db.execute("SELECT p.* FROM pipeline_entries p WHERE " + " AND ".join(where) + " ORDER BY p.updated_at DESC,p.id DESC",params).fetchall()
|
||||
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows]})
|
||||
|
||||
def list_stages(self, db, org):
|
||||
rows=db.execute("SELECT * FROM pipeline_stages WHERE organization_id=? AND active=1 ORDER BY position,id",(org,)).fetchall()
|
||||
if not rows: return self.send_json(200,{"organization_id":org,"items":[{"name":x,"position":i,"active":True} for i,x in enumerate(("new","contacted","qualified","proposal","negotiation","won","lost"))]})
|
||||
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows]})
|
||||
|
||||
def list_interactions(self, db, org, query):
|
||||
params=[org]; where=["i.organization_id=?"]
|
||||
bid=(query.get("business_id") or [""])[0]
|
||||
if bid.isdigit(): where.append("i.business_id=?"); params.append(int(bid))
|
||||
outcome=(query.get("outcome") or [""])[0]
|
||||
if outcome: where.append("i.outcome=?"); params.append(outcome)
|
||||
rows=db.execute("SELECT i.* FROM interactions i WHERE " + " AND ".join(where) + " ORDER BY i.created_at DESC,i.id DESC",params).fetchall()
|
||||
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows]})
|
||||
|
||||
def list_suppressions(self, db, org, query):
|
||||
rows=db.execute("SELECT * FROM suppressions WHERE organization_id=? ORDER BY id DESC",(org,)).fetchall()
|
||||
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows]})
|
||||
|
||||
def report(self, db, org, kind, query):
|
||||
try:
|
||||
if kind == "pipeline":
|
||||
params=[org]; clause=self._date_where(query,"p.created_at",params); sql="SELECT p.stage, p.status, COUNT(*) count FROM pipeline_entries p WHERE p.organization_id=?" + ((" AND "+clause) if clause else "") + " GROUP BY p.stage,p.status ORDER BY p.stage,p.status"
|
||||
elif kind == "outcomes":
|
||||
params=[org]; clause=self._date_where(query,"i.created_at",params); sql="SELECT i.outcome, COUNT(*) count FROM interactions i WHERE i.organization_id=?" + ((" AND "+clause) if clause else "") + " GROUP BY i.outcome ORDER BY i.outcome"
|
||||
else:
|
||||
params=[org]; clause=self._date_where(query,"activity_at",params); sql="SELECT activity_type, COUNT(*) count FROM (SELECT 'pipeline' activity_type, created_at activity_at FROM pipeline_entries WHERE organization_id=? UNION ALL SELECT 'interaction',created_at FROM interactions WHERE organization_id=?) WHERE 1=1" + ((" AND "+clause) if clause else "") + " GROUP BY activity_type"
|
||||
params=[org,org] + params[1:]
|
||||
rows=db.execute(sql,params).fetchall(); return self.send_json(200,{"organization_id":org,"items":[dict(r) for r in rows]})
|
||||
except ValueError as exc: return self.send_json(400,{"error":str(exc)})
|
||||
|
||||
def do_GET(self):
|
||||
parsed=urlparse(self.path); path=parsed.path.rstrip("/")
|
||||
if path=="/api/v1/health/live": return self.send_json(200,{"status":"ok","organization_id":ORGANIZATION_ID})
|
||||
@@ -252,6 +326,12 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
if path=="/api/v1/domain-checks": return self.list_domain_checks(db,org,parse_qs(parsed.query))
|
||||
if path=="/api/v1/website-scans": return self.list_website_scans(db,org,parse_qs(parsed.query))
|
||||
if path=="/api/v1/contact-extractions": return self.list_contact_extractions(db,org,parse_qs(parsed.query))
|
||||
if path=="/api/v1/pipeline-entries": return self.list_pipeline(db,org,parse_qs(parsed.query))
|
||||
if path=="/api/v1/pipeline-stages": return self.list_stages(db,org)
|
||||
if path=="/api/v1/outcomes": return self.send_json(200,{"items":sorted(self.OUTCOMES)})
|
||||
if path=="/api/v1/interactions": return self.list_interactions(db,org,parse_qs(parsed.query))
|
||||
if path=="/api/v1/suppressions": return self.list_suppressions(db,org,parse_qs(parsed.query))
|
||||
if path in ("/api/v1/reports/pipeline","/api/v1/reports/outcomes","/api/v1/reports/activity"): return self.report(db,org,path.rsplit('/',1)[1],parse_qs(parsed.query))
|
||||
if path.startswith("/api/v1/jobs/"): return self.get_job_route(db,org,path,parse_qs(parsed.query))
|
||||
if path.startswith("/api/v1/businesses/"):
|
||||
bits=path.split("/"); ident=bits[4] if len(bits)>4 else ""
|
||||
@@ -371,7 +451,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
key = str(payload.get("idempotency_key") or hashlib.sha256((str(scan_id or "") + source_url + source_html).encode()).hexdigest())[:200]
|
||||
existing = db.execute("SELECT * FROM contact_extractions WHERE organization_id=? AND business_id=? AND extraction_key=? ORDER BY id", (org, bid, key)).fetchall()
|
||||
if existing: return self.send_json(200, {"business_id": bid, "extraction_key": key, "items": [self._contact_json(r) for r in existing], "idempotent": True})
|
||||
suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?", (org,))]
|
||||
suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1", (org,))]
|
||||
try: found = extract_contacts(source_html, source_url, suppressions=suppressions, max_results=requested_limit)
|
||||
except ValueError as exc: return self.send_json(400, {"error": str(exc)})
|
||||
for item in found:
|
||||
@@ -536,7 +616,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
if drow:
|
||||
try: domain.update(json.loads(drow["result_json"] or "{}"))
|
||||
except (TypeError, ValueError): pass
|
||||
suppressed = is_suppressed(dict(business), [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?", (org,))])
|
||||
suppressed = is_suppressed(dict(business), [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1", (org,))])
|
||||
signals = signals_for_business(dict(business), website, contacts, domain, suppressed); self.ensure_score_rules(db, org); rules = [dict(r) for r in db.execute("SELECT * FROM score_rules WHERE organization_id=?", (org,))]; result = evaluate_score(signals, rules)
|
||||
override_score = payload.get("override_score"); override_eligible = payload.get("override_eligible")
|
||||
if override_score is not None or override_eligible is not None:
|
||||
@@ -619,6 +699,9 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
if path=="/api/v1/sources":return self.create_source(payload,db,user)
|
||||
if path=="/api/v1/discovery-queries":return self.create_query(payload,db,user)
|
||||
if path=="/api/v1/suppressions":return self.create_suppression(payload,db,user)
|
||||
if path=="/api/v1/suppressions/import":return self.import_suppressions(payload,db,user)
|
||||
if path=="/api/v1/pipeline-stages":return self.create_stage(payload,db,user)
|
||||
if path=="/api/v1/interactions": return self.send_json(400,{"error":"business_id_required"})
|
||||
if path=="/api/v1/contact-extractions": return self.send_json(405,{"error":"method_not_allowed"})
|
||||
if path=="/api/v1/imports/preview":return self.preview_import(payload,db,org)
|
||||
if len(path.split("/"))==7 and path.split("/")[3:6]==["businesses",path.split("/")[4],"websites"] and path.split("/")[6]=="scan": return self.scan_business_website(int(path.split("/")[4]) if path.split("/")[4].isdigit() else -1,payload,db,user)
|
||||
@@ -633,6 +716,8 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
return self.test_source(int(bits[4]),db,user) if bits[5]=="test" else self.ingest_source(int(bits[4]),payload,db,user)
|
||||
if len(bits)==6 and bits[3] == "discovery-queries" and bits[4].isdigit() and bits[5]=="run": return self.run_query(int(bits[4]),db,user)
|
||||
if len(bits)==7 and bits[:4]==["","api","v1","businesses"] and bits[5] in CHILD_TABLES and bits[6]=="": pass
|
||||
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5] == "pipeline": return self.update_pipeline(int(bits[4]) if bits[4].isdigit() else -1,payload,db,user)
|
||||
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5] == "interactions": return self.create_interaction(int(bits[4]) if bits[4].isdigit() else -1,payload,db,user)
|
||||
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5] in CHILD_TABLES:return self.create_child(int(bits[4]) if bits[4].isdigit() else -1,bits[5],payload,db,user)
|
||||
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5]=="verify":return self.verify_business(int(bits[4]) if bits[4].isdigit() else -1,payload,db,user)
|
||||
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5]=="merge":return self.merge_business(int(bits[4]) if bits[4].isdigit() else -1,payload,db,user)
|
||||
@@ -645,6 +730,10 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
if not user:return
|
||||
if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"})
|
||||
bits=path.split("/")
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","pipeline-stages"] and bits[4].isdigit(): return self.update_stage(int(bits[4]),self.read_json(),db,user)
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","suppressions"] and bits[4].isdigit(): return self.update_suppression(int(bits[4]),self.read_json(),db,user)
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","pipeline-entries"] and bits[4].isdigit(): return self.update_pipeline_entry(int(bits[4]),self.read_json(),db,user)
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","interactions"] and bits[4].isdigit(): return self.update_interaction(int(bits[4]),self.read_json(),db,user)
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","saved-filters"] and bits[4].isdigit(): return self.update_saved_filter(int(bits[4]),self.read_json(),db,user)
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","score-rules"] and bits[4].isdigit(): return self.update_score_rule(int(bits[4]),self.read_json(),db,user)
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","sources"] and bits[4].isdigit(): return self.update_source(int(bits[4]),self.read_json(),db,user)
|
||||
@@ -658,6 +747,13 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
if not user:return
|
||||
if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"})
|
||||
bits=path.split("/")
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","pipeline-stages"] and bits[4].isdigit(): return self.delete_crm_item("pipeline_stages",int(bits[4]),db,user)
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","pipeline-entries"] and bits[4].isdigit(): return self.delete_crm_item("pipeline_entries",int(bits[4]),db,user)
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","interactions"] and bits[4].isdigit(): return self.delete_crm_item("interactions",int(bits[4]),db,user)
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","suppressions"] and bits[4].isdigit():
|
||||
row=db.execute("SELECT id FROM suppressions WHERE id=? AND organization_id=?",(int(bits[4]),user["organization_id"])).fetchone()
|
||||
if not row:return self.send_json(404,{"error":"not_found"})
|
||||
db.execute("DELETE FROM suppressions WHERE id=? AND organization_id=?",(int(bits[4]),user["organization_id"]));self.audit(db,user,"suppression.deleted",bits[4]);db.commit();return self.send_json(200,{"ok":True,"id":int(bits[4])})
|
||||
if len(bits)==5 and bits[:4]==["","api","v1","saved-filters"] and bits[4].isdigit(): return self.delete_saved_filter(int(bits[4]),db,user)
|
||||
return self.send_json(404,{"error":"not_found"})
|
||||
finally: db.close()
|
||||
@@ -671,7 +767,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
def create_business(self,payload,db,user):
|
||||
org=user["organization_id"]
|
||||
if not str(payload.get("name","")).strip():return self.send_json(400,{"error":"name_required"})
|
||||
b=normalize_business(payload); suppressions=[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?",(org,))]
|
||||
b=normalize_business(payload); suppressions=[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1",(org,))]
|
||||
if is_suppressed(b,suppressions):return self.send_json(409,{"error":"suppressed"})
|
||||
fields=[(c,b[c]) for c in ("website_domain","email","phone") if b[c]]
|
||||
if fields and db.execute("SELECT id FROM businesses WHERE organization_id=? AND ("+" OR ".join(f"{c}=?" for c,_ in fields)+")",[org]+[v for _,v in fields]).fetchone():return self.send_json(409,{"error":"duplicate"})
|
||||
@@ -679,16 +775,16 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
def create_suppression(self,payload,db,user):
|
||||
kind,value=payload.get("kind"),str(payload.get("value","")).strip().lower()
|
||||
if kind not in {"email","domain","phone"} or not value:return self.send_json(400,{"error":"invalid_suppression"})
|
||||
try:db.execute("INSERT INTO suppressions(organization_id,kind,value) VALUES(?,?,?)",(user["organization_id"],kind,value))
|
||||
try:db.execute("INSERT INTO suppressions(organization_id,kind,value,actor_user_id,active) VALUES(?,?,?,?,1)",(user["organization_id"],kind,value,user["id"]))
|
||||
except sqlite3.IntegrityError:pass
|
||||
self.audit(db,user,"suppression.created",kind);db.commit();return self.send_json(201,dict(db.execute("SELECT * FROM suppressions WHERE organization_id=? AND kind=? AND value=?",(user["organization_id"],kind,value)).fetchone()))
|
||||
self.audit(db,user,"suppression.created",kind);db.commit();return self.send_json(201,row_json(db.execute("SELECT * FROM suppressions WHERE organization_id=? AND kind=? AND value=?",(user["organization_id"],kind,value)).fetchone()))
|
||||
def child_business(self,db,bid,user):return self.business(db,bid,user["organization_id"])
|
||||
def create_child(self,bid,table,payload,db,user):
|
||||
if not self.child_business(db,bid,user):return self.send_json(404,{"error":"not_found"})
|
||||
if table=="contacts":
|
||||
email=str(payload.get("email","")).strip().lower(); phone=normalize_phone(payload.get("phone"));
|
||||
if email and not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$",email):return self.send_json(400,{"error":"invalid_contact"})
|
||||
suppressed=is_suppressed({"email":email,"phone":phone},[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?",(user["organization_id"],))]); values=(str(payload.get("name","")).strip(),email,phone,str(payload.get("title","")).strip(),int(bool(payload.get("do_not_contact"))) or int(suppressed))
|
||||
suppressed=is_suppressed({"email":email,"phone":phone},[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1",(user["organization_id"],))]); values=(str(payload.get("name","")).strip(),email,phone,str(payload.get("title","")).strip(),int(bool(payload.get("do_not_contact"))) or int(suppressed))
|
||||
elif table=="domains":
|
||||
value=normalize_domain(payload.get("domain"));
|
||||
if not value:return self.send_json(400,{"error":"invalid_domain"})
|
||||
@@ -705,15 +801,100 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
values=(str(payload["body"]).strip(),)
|
||||
columns=CHILD_TABLES[table]; db.execute(f"INSERT INTO {table}(business_id,organization_id,{','.join(columns)}) VALUES(?, ?, {','.join('?' for _ in columns)})",(bid,user["organization_id"])+values); rid=db.execute("SELECT last_insert_rowid()").fetchone()[0];self.audit(db,user,f"{table}.created",str(rid));db.commit();return self.send_json(201,row_json(db.execute(f"SELECT * FROM {table} WHERE id=?",(rid,)).fetchone()))
|
||||
def update_pipeline(self,bid,payload,db,user):
|
||||
if not self.child_business(db,bid,user) or not str(payload.get("stage","")).strip():return self.send_json(404 if not self.child_business(db,bid,user) else 400,{"error":"not_found" if not self.child_business(db,bid,user) else "stage_required"})
|
||||
stage=str(payload["stage"]).strip();status=str(payload.get("status","active")).strip() or "active";db.execute("INSERT INTO pipeline_entries(business_id,organization_id,stage,status) VALUES(?,?,?,?)",(bid,user["organization_id"],stage,status));rid=db.execute("SELECT last_insert_rowid()").fetchone()[0];self.audit(db,user,"pipeline.updated",stage);db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM pipeline_entries WHERE id=?",(rid,)).fetchone()))
|
||||
if not self.child_business(db,bid,user): return self.send_json(404,{"error":"not_found"})
|
||||
stage=str(payload.get("stage","")).strip(); status=str(payload.get("status","active")).strip() or "active"
|
||||
if not stage or stage not in self.STAGES or status not in {"active","won","lost","paused"}: return self.send_json(400,{"error":"invalid_pipeline"})
|
||||
key=str(payload.get("idempotency_key","")).strip(); org=user["organization_id"]
|
||||
if key:
|
||||
prior=db.execute("SELECT * FROM pipeline_entries WHERE organization_id=? AND idempotency_key=?",(org,key)).fetchone()
|
||||
if prior:return self.send_json(200,row_json(prior))
|
||||
suppressed=self._crm_suppressed(db,org,self.business(db,bid,org))
|
||||
if suppressed:return self.send_json(409,{"error":"do_not_contact","outreach_disabled":True})
|
||||
cur=db.execute("INSERT INTO pipeline_entries(business_id,organization_id,stage,status,notes,next_action,follow_up_at,actor_user_id,idempotency_key) VALUES(?,?,?,?,?,?,?,?,?)",(bid,org,stage,status,str(payload.get("notes",payload.get("body","")))[:5000],str(payload.get("next_action",""))[:500],payload.get("follow_up_at"),user["id"],key or None)); rid=cur.lastrowid
|
||||
self.audit(db,user,"pipeline.created",str(rid)); db.commit(); return self.send_json(200 if getattr(self,"command","")=="PATCH" else 201,row_json(db.execute("SELECT * FROM pipeline_entries WHERE id=?",(rid,)).fetchone()))
|
||||
|
||||
def update_pipeline_entry(self,eid,payload,db,user):
|
||||
org=user["organization_id"]; row=db.execute("SELECT * FROM pipeline_entries WHERE id=? AND organization_id=?",(eid,org)).fetchone()
|
||||
if not row:return self.send_json(404,{"error":"not_found"})
|
||||
if payload.get("expected_updated_at") and payload["expected_updated_at"] != row["updated_at"]: return self.send_json(409,{"error":"conflict"})
|
||||
values={k:payload[k] for k in ("stage","status","notes","next_action","follow_up_at") if k in payload}
|
||||
if "stage" in values and values["stage"] not in self.STAGES:return self.send_json(400,{"error":"invalid_stage"})
|
||||
if "status" in values and values["status"] not in {"active","won","lost","paused"}:return self.send_json(400,{"error":"invalid_status"})
|
||||
if not values:return self.send_json(400,{"error":"no_changes"})
|
||||
cols=[]; args=[]
|
||||
for k,v in values.items():cols.append(k+"=?");args.append(v)
|
||||
args += [eid,org]; db.execute("UPDATE pipeline_entries SET "+",".join(cols)+",version=version+1,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",args); self.audit(db,user,"pipeline.updated",str(eid));db.commit()
|
||||
return self.send_json(200,row_json(db.execute("SELECT * FROM pipeline_entries WHERE id=?",(eid,)).fetchone()))
|
||||
|
||||
def create_interaction(self,bid,payload,db,user):
|
||||
org=user["organization_id"]; business=self.business(db,bid,org)
|
||||
if not business:return self.send_json(404,{"error":"not_found"})
|
||||
outcome=str(payload.get("outcome","other")).strip().lower(); kind=str(payload.get("kind","")).strip().lower()
|
||||
if not kind or outcome not in self.OUTCOMES:return self.send_json(400,{"error":"invalid_interaction"})
|
||||
if self._crm_suppressed(db,org,business):return self.send_json(409,{"error":"do_not_contact","outreach_disabled":True})
|
||||
key=str(payload.get("idempotency_key","")).strip()
|
||||
if key:
|
||||
prior=db.execute("SELECT * FROM interactions WHERE organization_id=? AND idempotency_key=?",(org,key)).fetchone()
|
||||
if prior:return self.send_json(200,row_json(prior))
|
||||
cur=db.execute("INSERT INTO interactions(business_id,organization_id,kind,body,outcome,notes,next_action,follow_up_at,actor_user_id,idempotency_key) VALUES(?,?,?,?,?,?,?,?,?,?)",(bid,org,kind,str(payload.get("body",payload.get("notes","")))[:5000],outcome,str(payload.get("notes",""))[:5000],str(payload.get("next_action",""))[:500],payload.get("follow_up_at"),user["id"],key or None)); self.audit(db,user,"interaction.created",str(cur.lastrowid));db.commit()
|
||||
return self.send_json(201,row_json(db.execute("SELECT * FROM interactions WHERE id=?",(cur.lastrowid,)).fetchone()))
|
||||
|
||||
def update_interaction(self,iid,payload,db,user):
|
||||
org=user["organization_id"]; row=db.execute("SELECT * FROM interactions WHERE id=? AND organization_id=?",(iid,org)).fetchone()
|
||||
if not row:return self.send_json(404,{"error":"not_found"})
|
||||
values={k:payload[k] for k in ("kind","body","outcome","notes","next_action","follow_up_at") if k in payload}
|
||||
if "outcome" in values and values["outcome"] not in self.OUTCOMES:return self.send_json(400,{"error":"invalid_outcome"})
|
||||
if not values:return self.send_json(400,{"error":"no_changes"})
|
||||
cols=[];args=[]
|
||||
for k,v in values.items():cols.append(k+"=?");args.append(v)
|
||||
args += [iid,org];db.execute("UPDATE interactions SET "+",".join(cols)+",updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",args);self.audit(db,user,"interaction.updated",str(iid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM interactions WHERE id=?",(iid,)).fetchone()))
|
||||
|
||||
def update_suppression(self,sid,payload,db,user):
|
||||
row=db.execute("SELECT * FROM suppressions WHERE id=? AND organization_id=?",(sid,user["organization_id"])).fetchone()
|
||||
if not row:return self.send_json(404,{"error":"not_found"})
|
||||
if "active" not in payload:return self.send_json(400,{"error":"active_required"})
|
||||
db.execute("UPDATE suppressions SET active=?,actor_user_id=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",(int(bool(payload["active"])),user["id"],sid,user["organization_id"]));self.audit(db,user,"suppression.updated",str(sid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM suppressions WHERE id=?",(sid,)).fetchone()))
|
||||
|
||||
def import_suppressions(self,payload,db,user):
|
||||
items=payload.get("items",payload.get("suppressions"));
|
||||
if not isinstance(items,list) or len(items)>1000:return self.send_json(400,{"error":"invalid_suppression_import"})
|
||||
imported=0
|
||||
for item in items:
|
||||
if not isinstance(item,dict) or item.get("kind") not in {"email","domain","phone"} or not str(item.get("value","")).strip():return self.send_json(400,{"error":"invalid_suppression"})
|
||||
kind=item["kind"];value=str(item["value"]).strip().lower(); cur=db.execute("INSERT OR IGNORE INTO suppressions(organization_id,kind,value,actor_user_id) VALUES(?,?,?,?)",(user["organization_id"],kind,value,user["id"]));imported += cur.rowcount
|
||||
self.audit(db,user,"suppressions.imported",str(imported));db.commit();return self.send_json(201,{"imported":imported,"received":len(items)})
|
||||
|
||||
def create_stage(self,payload,db,user):
|
||||
name=str(payload.get("name","")).strip().lower()
|
||||
if not name or len(name)>80 or not re.match(r"^[a-z0-9_-]+$",name):return self.send_json(400,{"error":"invalid_stage"})
|
||||
try: position=int(payload.get("position",0))
|
||||
except (TypeError,ValueError):return self.send_json(400,{"error":"invalid_stage"})
|
||||
try:cur=db.execute("INSERT INTO pipeline_stages(organization_id,name,position) VALUES(?,?,?)",(user["organization_id"],name,position))
|
||||
except sqlite3.IntegrityError:return self.send_json(409,{"error":"duplicate_stage"})
|
||||
self.audit(db,user,"pipeline_stage.created",str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT * FROM pipeline_stages WHERE id=?",(cur.lastrowid,)).fetchone()))
|
||||
|
||||
def update_stage(self,sid,payload,db,user):
|
||||
row=db.execute("SELECT * FROM pipeline_stages WHERE id=? AND organization_id=?",(sid,user["organization_id"])).fetchone()
|
||||
if not row:return self.send_json(404,{"error":"not_found"})
|
||||
values={k:payload[k] for k in ("name","position","active") if k in payload}
|
||||
if not values:return self.send_json(400,{"error":"no_changes"})
|
||||
if "name" in values and (not isinstance(values["name"],str) or not re.match(r"^[a-z0-9_-]+$",values["name"])):return self.send_json(400,{"error":"invalid_stage"})
|
||||
cols=[];args=[]
|
||||
for k,v in values.items():cols.append(k+"=?");args.append(int(bool(v)) if k=="active" else v)
|
||||
args += [sid,user["organization_id"]];db.execute("UPDATE pipeline_stages SET "+",".join(cols)+",updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",args);self.audit(db,user,"pipeline_stage.updated",str(sid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM pipeline_stages WHERE id=?",(sid,)).fetchone()))
|
||||
|
||||
def delete_crm_item(self,table,ident,db,user):
|
||||
row=db.execute(f"SELECT id FROM {table} WHERE id=? AND organization_id=?",(ident,user["organization_id"])).fetchone()
|
||||
if not row:return self.send_json(404,{"error":"not_found"})
|
||||
db.execute(f"DELETE FROM {table} WHERE id=? AND organization_id=?",(ident,user["organization_id"]));self.audit(db,user,table+".deleted",str(ident));db.commit();return self.send_json(200,{"ok":True,"id":ident})
|
||||
|
||||
def verify_business(self,bid,payload,db,user):
|
||||
if not self.child_business(db,bid,user):return self.send_json(404,{"error":"not_found"})
|
||||
verified=bool(payload.get("verified",True)); now=datetime.now(timezone.utc).replace(microsecond=0).isoformat();db.execute("UPDATE businesses SET verified=?,verified_at=?,review_status=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",(int(verified),now if verified else None,"verified" if verified else "pending",bid,user["organization_id"]));self.audit(db,user,"business.verified",str(verified));db.commit();row=self.business(db,bid,user["organization_id"]);return self.send_json(200,row_json(row))
|
||||
def preview_import(self,payload,db,org):
|
||||
rows=payload.get("rows",[])
|
||||
if not isinstance(rows,list):return self.send_json(400,{"error":"rows_required"})
|
||||
normalized=deduplicate_businesses([r for r in rows if isinstance(r,dict) and str(r.get("name","")).strip()]); suppressions=[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?",(org,))];existing=[row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id=?",(org,))];seen=set();accepted=[];suppressed=0;existing_keys={deduplication_key(x) for x in existing}
|
||||
normalized=deduplicate_businesses([r for r in rows if isinstance(r,dict) and str(r.get("name","")).strip()]); suppressions=[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1",(org,))];existing=[row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id=?",(org,))];seen=set();accepted=[];suppressed=0;existing_keys={deduplication_key(x) for x in existing}
|
||||
for b in normalized:
|
||||
key=deduplication_key(b)
|
||||
if is_suppressed(b,suppressions):suppressed+=1
|
||||
|
||||
@@ -89,6 +89,16 @@ CREATE INDEX IF NOT EXISTS idx_pipeline_stage ON pipeline_entries(organization_i
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_business ON notes(business_id,created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_business ON interactions(business_id,created_at);
|
||||
|
||||
-- Phase 12 CRM workflow metadata and safe, auditable state.
|
||||
CREATE TABLE IF NOT EXISTS pipeline_stages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(organization_id,name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pipeline_stages_org ON pipeline_stages(organization_id,position,id);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_org_created ON interactions(organization_id,created_at DESC,id DESC);
|
||||
|
||||
-- Phase 4 durable background jobs (additive-safe for existing databases).
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
from http.client import HTTPConnection
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from app.main import create_server
|
||||
|
||||
|
||||
class Phase12Tests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = TemporaryDirectory(); os.environ['BOOTSTRAP_ADMIN_EMAIL']='p12@example.test'; os.environ['BOOTSTRAP_ADMIN_PASSWORD']='password-p12'
|
||||
self.server=create_server('127.0.0.1',0,self.tmp.name+'/db.sqlite'); self.thread=threading.Thread(target=self.server.serve_forever,daemon=True); self.thread.start(); self.conn=HTTPConnection('127.0.0.1',self.server.server_port); self.cookie=None
|
||||
self.request('POST','/api/v1/auth/login',{'email':'p12@example.test','password':'password-p12'})
|
||||
def tearDown(self):
|
||||
self.server.shutdown(); self.server.server_close(); self.thread.join(timeout=2); os.environ.pop('BOOTSTRAP_ADMIN_EMAIL',None); os.environ.pop('BOOTSTRAP_ADMIN_PASSWORD',None); self.tmp.cleanup()
|
||||
def request(self,m,p,b=None):
|
||||
h={'Content-Type':'application/json'} if b is not None else {}; h['Cookie']=self.cookie if self.cookie else ''
|
||||
self.conn.request(m,p,json.dumps(b).encode() if b is not None else None,h); r=self.conn.getresponse(); sc=r.getheader('Set-Cookie');
|
||||
if sc: self.cookie=sc.split(';',1)[0]
|
||||
return r.status,json.loads(r.read() or b'{}')
|
||||
def test_pipeline_interaction_transition_and_no_contact(self):
|
||||
s,b=self.request('POST','/api/v1/businesses',{'name':'CRM Co','email':'crm@example.test'}); self.assertEqual(s,201)
|
||||
s,p=self.request('POST',f"/api/v1/businesses/{b['id']}/pipeline",{'stage':'new','status':'active','notes':'first','next_action':'qualify','follow_up_at':'2026-09-10T10:00:00Z','idempotency_key':'p1'}); self.assertEqual(s,201); self.assertEqual(p['actor_user_id'],1)
|
||||
s,p2=self.request('PATCH',f"/api/v1/pipeline-entries/{p['id']}",{'stage':'qualified','expected_updated_at':p['updated_at']}); self.assertEqual(s,200); self.assertEqual(p2['stage'],'qualified')
|
||||
s,i=self.request('POST',f"/api/v1/businesses/{b['id']}/interactions",{'kind':'call','outcome':'connected','notes':'good fit','next_action':'demo'}); self.assertEqual(s,201); self.assertEqual(i['outcome'],'connected')
|
||||
self.assertEqual(self.request('GET','/api/v1/reports/pipeline')[0],200); self.assertEqual(self.request('GET','/api/v1/reports/outcomes')[0],200); self.assertEqual(self.request('GET','/api/v1/reports/activity')[0],200)
|
||||
def test_suppression_center_bulk_and_enforces_pipeline_interaction(self):
|
||||
s,b=self.request('POST','/api/v1/businesses',{'name':'Blocked CRM','email':'blocked@example.test'}); self.assertEqual(s,201)
|
||||
s,x=self.request('POST','/api/v1/suppressions/import',{'items':[{'kind':'email','value':'blocked@example.test'},{'kind':'domain','value':'bad.example'}]}); self.assertEqual(s,201); self.assertEqual(x['imported'],2)
|
||||
s,l=self.request('GET','/api/v1/suppressions'); self.assertEqual(s,200); self.assertEqual(len(l['items']),2)
|
||||
self.assertEqual(self.request('POST',f"/api/v1/businesses/{b['id']}/pipeline",{'stage':'new'})[0],409)
|
||||
self.assertEqual(self.request('POST',f"/api/v1/businesses/{b['id']}/interactions",{'kind':'call','outcome':'connected'})[0],409)
|
||||
sid=l['items'][0]['id']; self.assertEqual(self.request('PATCH',f'/api/v1/suppressions/{sid}',{'active':False})[0],200)
|
||||
self.assertEqual(self.request('DELETE',f'/api/v1/suppressions/{sid}')[0],200)
|
||||
def test_validation_and_date_bounds(self):
|
||||
s,b=self.request('POST','/api/v1/businesses',{'name':'Validation CRM'}); self.assertEqual(s,201)
|
||||
self.assertEqual(self.request('POST',f"/api/v1/businesses/{b['id']}/interactions",{'kind':'call','outcome':'not-a-real-outcome'})[0],400)
|
||||
self.assertEqual(self.request('GET','/api/v1/reports/activity?from=2020-01-01&to=2035-01-01')[0],400)
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -96,6 +96,18 @@ The UI must expose audit context for saved-filter changes, queue decisions, bulk
|
||||
|
||||
The current Phase 11 client now renders saved-view controls, a review queue capped at 100 visible records, selectable rows, and explicit verify/reject bulk review actions. It also renders clickable dashboard metric cards. Current limitations are material: saved views can be created/loaded/deleted in the client but update is not exposed; queue selection is visible-row-only and the UI does not show a server maximum/preview/per-record outcomes; dashboard links use client filter shortcuts rather than a complete server predicate; and suppression/merge eligibility and audit results still depend on the API response. No bulk action sends outreach or auto-merges.
|
||||
|
||||
## Phase 12 CRM UI contract
|
||||
|
||||
The Phase 12 UI presents a tenant-scoped pipeline, append-only interaction timeline, normalized outcomes, bounded reporting, and a suppression center. It must show the exact tenant/filter/as-of/timezone scope of every view and distinguish page counts, matching-set counts, event counts, and distinct-business counts. Loading, stale, unavailable, and error states are not zero. The API is authoritative; a hidden field, report ID, saved filter, or visible row cannot grant access.
|
||||
|
||||
Pipeline controls display the configured stages (`new`, `contacted`, `qualified`, `proposal`, `negotiation`, `won`, `lost`) and require an explicit reason for `won`, `lost`, and any configured reopen action. The UI must not offer direct jumps, edit historical transitions, or advance a stage merely because an interaction was added. Interactions show channel, actor, occurred time, provenance, safe summary, and outcome. Corrections are visibly appended/superseding, not destructive edits. `other` is distinct from a success or failure claim.
|
||||
|
||||
The outcome vocabulary is `connected`, `no_answer`, `left_message`, `meeting_booked`, `meeting_held`, `qualified`, `disqualified`, `won`, `lost`, and `other`. `other` is explicit uncertainty/catch-all metadata, not proof of success or failure; the UI must not invent an outcome for missing data. `do_not_contact` is a separate persistent suppression state, not a deliverability or engagement outcome, and must disable contact-related controls.
|
||||
|
||||
The suppression center shows normalized identifier, source, reason, scope, actor, effective time, and audit context. It must apply to records before display/export/report eligibility and must never silently delete a suppressed record. Unsuppression/removal is an explicit authorized action with confirmation and reason. Report and export screens must show freshness, as-of, timezone, filter snapshot, retention class where applicable, and safe partial/per-record results; they must not imply deliverability or outreach permission.
|
||||
|
||||
There is no send button, message composer, SMTP probe, validation email, campaign, delivery scheduler, or automated follow-up in Phase 12. The browser never contacts a prospect. Suppression, pipeline, outcome, report, and audit controls are presentation layers over server enforcement. The UI remains pilot-grade until browser/API smoke coverage verifies transition rejection, append-only corrections, outcome taxonomy, suppression precedence, report semantics, retention states, and cross-tenant non-disclosure.
|
||||
|
||||
## Remaining limitations
|
||||
|
||||
The static client has no client-side crawler, scanner, contact extractor, enrichment scheduler, outreach integration, availability provider, or SSE delivery. It can display server-provided Phase 9 observations, but production still requires server-side official-site scoping, SSRF/DNS-rebinding/redirect controls, hard extraction/page/byte/time/candidate budgets, durable history/cache isolation and retention/deletion, abuse/rate controls, suppression regression tests, and authenticated provenance/audit coverage. For domain intelligence, display registrable-domain/PSL version and unresolved reasons, DNS status and freshness/TTL (not “available”), independent MX/NS/TXT uncertainty, and association confidence with explainable evidence. Never auto-attach candidate domains or treat `nxdomain`/`no_data` as availability. CSV preview is capped for display and is not an import workflow.
|
||||
|
||||
+25
-2
@@ -160,7 +160,29 @@
|
||||
async function saveStage(form){const stage=new FormData(form).get('stage');if(!stage){message('pipelineMessage','Choose a pipeline stage.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/pipeline`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage})});message('pipelineMessage','Pipeline stage updated.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('pipelineMessage',e.message,true);}}
|
||||
async function verify(){try{await jsonRequest(`/api/v1/businesses/${selectedId}/verify`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({verified:true})});message('verifyMessage','Prospect marked verified.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('verifyMessage',e.message,true);}}
|
||||
async function addProspect(event){event.preventDefault();const data=Object.fromEntries(new FormData(event.currentTarget).entries());const msg=$('formMessage');try{const body=await jsonRequest('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});prospects.unshift(body);msg.textContent='Added to review queue.';event.currentTarget.reset();renderMetrics(null);renderRows();}catch(e){if(e.message!=='unauthorized'){msg.textContent=e.message;msg.className='form-message error';}}}
|
||||
let jobs = [], selectedJobId = null, jobPollTimer = null;
|
||||
// Phase 12 CRM surfaces: all actions are authenticated internal records; no send/outreach capability.
|
||||
let crmPipelineItems = [], crmListMode = false, selectedSuppressionIds = new Set();
|
||||
const crmStages = ['new','qualified','review','contacted','meeting','won','lost','suppressed'];
|
||||
const crmLabel = value => String(value || 'unknown').replaceAll('_',' ').replace(/\\b\\w/g, c => c.toUpperCase());
|
||||
const crmArray = (payload, keys=[]) => payload && Array.isArray(payload) ? payload : (keys.map(k => payload?.[k]).find(Array.isArray) || []);
|
||||
const crmStage = p => String(p.pipeline_stage || p.stage || p.pipeline?.stage || (Array.isArray(p.pipeline) ? p.pipeline.at(-1)?.stage : '') || 'new').toLowerCase();
|
||||
function crmMessage(text, error=false){const el=$('crmMessage');if(el){el.textContent=text||'';el.className=`crm-message${error?' error':''}`;}}
|
||||
function renderPipeline(){const board=$('pipelineBoard');if(!board)return;if(!crmPipelineItems.length){board.innerHTML='<div class="crm-empty">No pipeline records returned by the workspace.</div>';return;}const card=p=>{const st=statusOf(p), suppressed=st==='suppressed'||p.suppressed===true;return `<article class="pipeline-card ${suppressed?'is-suppressed':''}" data-prospect-id="${esc(p.id)}"><button class="pipeline-card-link" type="button" data-crm-select="${esc(p.id)}"><strong>${esc(p.name||`Prospect ${p.id}`)}</strong><small>${esc(p.website_domain||p.location||'No domain')}</small><span class="score ${scoreClass(scoreFor(p))}">${esc(scoreFor(p))} / 100</span></button><div class="pipeline-card-actions"><label class="sr-only" for="crm-stage-${esc(p.id)}">Stage for ${esc(p.name)}</label><select id="crm-stage-${esc(p.id)}" data-crm-stage="${esc(p.id)}">${crmStages.map(s=>`<option value="${s}" ${crmStage(p)===s?'selected':''}>${crmLabel(s)}</option>`).join('')}</select><button class="button ghost compact" type="button" data-crm-save-stage="${esc(p.id)}">Save</button></div>${suppressed?'<p class="suppression-inline">Suppressed · no outreach</p>':st==='review'?'<p class="review-inline">Needs review before outreach</p>':''}</article>`;};if(crmListMode){board.className='pipeline-board pipeline-list-view';board.innerHTML=`<div class="pipeline-list">${crmPipelineItems.map(card).join('')}</div>`;}else{board.className='pipeline-board';board.innerHTML=crmStages.map(stage=>{const items=crmPipelineItems.filter(p=>crmStage(p)===stage);return `<section class="pipeline-column" data-stage="${stage}"><div class="pipeline-column-head"><h3>${crmLabel(stage)}</h3><span class="count">${items.length}</span></div>${items.length?items.map(card).join(''):'<p class="crm-column-empty">No prospects</p>'}</section>`;}).join('');}}
|
||||
async function loadCrmPipeline(){const board=$('pipelineBoard');if(board)board.innerHTML='<div class="detail-loading" aria-live="polite">Loading CRM pipeline…</div>';try{const [businessPayload,pipelinePayload]=await Promise.all([jsonRequest('/api/v1/businesses?page=1&page_size=100'),jsonRequest('/api/v1/pipeline-entries')]);const businesses=crmArray(businessPayload,['items','businesses','prospects']),entries=crmArray(pipelinePayload,['items','entries','pipeline']);const byId=new Map(businesses.map(p=>[String(p.id),p]));crmPipelineItems=entries.map(entry=>({...byId.get(String(entry.business_id||entry.prospect_id))||{},...entry,id:entry.business_id||entry.prospect_id||entry.id}));if(!crmPipelineItems.length)crmPipelineItems=businesses;renderPipeline();crmMessage('');}catch(error){if(error.message!=='unauthorized'){if(board)board.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load CRM pipeline</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryCrmBtn" type="button">Try again</button></div>`;crmMessage(error.message,true);}}}
|
||||
async function saveCrmStage(id){const item=crmPipelineItems.find(p=>String(p.id)===String(id)),select=$(`crm-stage-${id}`);if(!item||!select)return;const next=select.value;if(next===crmStage(item))return;const suppressed=statusOf(item)==='suppressed';if(suppressed&&next!=='suppressed'){window.alert('Suppressed prospects must remain in Suppressed. Remove the suppression rule first if policy allows.');select.value='suppressed';return;}if(!window.confirm(`Move ${item.name||`Prospect ${id}`} to ${crmLabel(next)}? This records an internal stage transition only; no outreach will be sent.`)){select.value=crmStage(item);return;}try{await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/pipeline`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage:next,reason:'CRM operator transition',outreach:false})});crmMessage('Stage transition saved.');await Promise.all([loadCrmPipeline(),loadData()]);}catch(error){if(error.message!=='unauthorized')crmMessage(error.message,true);select.value=crmStage(item);}}
|
||||
async function loadInteractions(id){const state=$('interactionState');if(!state)return;state.innerHTML='<div class="detail-loading" aria-live="polite">Loading interaction timeline…</div>';try{const payload=await jsonRequest(`/api/v1/interactions?business_id=${encodeURIComponent(id)}`),items=crmArray(payload,['interactions','items','timeline','events']);state.innerHTML=`<div class="panel-heading"><div><p class="eyebrow">TIMELINE</p><h3>${esc((crmPipelineItems.find(p=>String(p.id)===String(id))||selectedDetail||{}).name||`Prospect ${id}`)}</h3></div><span class="small-label">${items.length} record${items.length===1?'':'s'}</span></div>${items.length?`<ol class="crm-timeline">${items.map(item=>`<li><span class="timeline-dot"></span><div><strong>${esc(item.type||item.kind||'Interaction')}</strong><span class="outcome-chip">${esc(crmLabel(item.outcome||'No outcome'))}</span><p>${esc(item.summary||item.notes||item.body||'No summary')}</p><small>${esc(item.follow_up_at?`Follow-up ${item.follow_up_at} · `:'')}${esc(item.created_at||item.occurred_at||'Time unavailable')}</small></div></li>`).join('')}</ol>`:'<p class="crm-empty">No interactions recorded yet.</p>'}`;state.dataset.businessId=id;}catch(error){if(error.message!=='unauthorized')state.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load interaction timeline</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryInteractionsBtn" type="button">Try again</button></div>`;}}
|
||||
async function saveInteraction(event){event.preventDefault();if(!selectedId){message('interactionMessage','Select a prospect in the pipeline first.',true);return;}const form=event.currentTarget,data=Object.fromEntries(new FormData(form).entries());if(!data.summary.trim()){message('interactionMessage','Add an internal summary before saving.',true);return;}const button=form.querySelector('button[type="submit"]');button.disabled=true;try{await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/interactions`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...data,outreach:false})});message('interactionMessage','Interaction saved. No message was sent.');form.reset();await loadInteractions(selectedId);}catch(error){if(error.message!=='unauthorized')message('interactionMessage',error.message,true);}finally{button.disabled=false;}}
|
||||
let suppressions=[];
|
||||
function renderSuppressions(){const state=$('suppressionState');if(!state)return;if(!suppressions.length){state.innerHTML='<div class="crm-empty">No suppression rules returned by the workspace.</div>';return;}state.innerHTML=`<div class="suppression-list">${suppressions.map(item=>`<article class="suppression-row"><label class="checkbox-label"><input type="checkbox" data-suppression-id="${esc(item.id)}" ${selectedSuppressionIds.has(String(item.id))?'checked':''}><span><strong>${esc(item.value||item.identifier||'Redacted value')}</strong><small>${esc(crmLabel(item.kind||item.type||'rule'))}${item.reason?` · ${esc(item.reason)}`:''}</small></span></label><button class="button danger compact" type="button" data-remove-suppression="${esc(item.id)}">Remove</button></article>`).join('')}</div>`;updateSuppressionSelection();}
|
||||
function updateSuppressionSelection(){const count=selectedSuppressionIds.size;const btn=$('bulkReviewSuppressionsBtn');if(btn){btn.disabled=!count;btn.textContent=count?`Review selected (${count})`:'Review selected';}const all=$('selectAllSuppressions');if(all)all.checked=Boolean(suppressions.length&&count===suppressions.length);}
|
||||
async function loadSuppressions(){const state=$('suppressionState');if(state)state.innerHTML='<div class="detail-loading" aria-live="polite">Loading suppression rules…</div>';try{const payload=await jsonRequest('/api/v1/suppressions');suppressions=crmArray(payload,['suppressions','items','rules']);renderSuppressions();}catch(error){if(error.message!=='unauthorized'&&state)state.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load suppressions</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retrySuppressionsBtn" type="button">Try again</button></div>`;}}
|
||||
async function addSuppression(event){event.preventDefault();const form=event.currentTarget,data=Object.fromEntries(new FormData(form).entries());if(!data.value.trim()){message('suppressionMessage','A suppression value is required.',true);return;}if(!window.confirm(`Add ${data.kind} suppression for ${data.value.trim()}? This is a do-not-contact rule.`))return;try{await jsonRequest('/api/v1/suppressions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...data,value:data.value.trim()})});message('suppressionMessage','Suppression added. Matching records are now unavailable for outreach.');form.reset();await Promise.all([loadSuppressions(),loadData()]);}catch(error){if(error.message!=='unauthorized')message('suppressionMessage',error.message,true);}}
|
||||
async function removeSuppression(id){if(!window.confirm('Remove this suppression rule? Confirm only if policy allows contact eligibility to be reconsidered.'))return;try{await jsonRequest(`/api/v1/suppressions/${encodeURIComponent(id)}`,{method:'DELETE'});selectedSuppressionIds.delete(String(id));await loadSuppressions();}catch(error){if(error.message!=='unauthorized')message('suppressionMessage',error.message,true);}}
|
||||
async function bulkReviewSuppressions(){const ids=[...selectedSuppressionIds];if(!ids.length)return;if(!window.confirm(`Review ${ids.length} suppression rule${ids.length===1?'':'s'}? This will not remove or contact any record.`))return;try{await jsonRequest('/api/v1/suppressions/bulk-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({suppression_ids:ids,review_only:true,outreach:false})});message('suppressionMessage','Suppression review recorded; no rules were changed.');}catch(error){if(error.message!=='unauthorized')message('suppressionMessage',error.message,true);}}
|
||||
const reportRows=(payload, keys=['items','rows','data'])=>crmArray(payload,keys);
|
||||
function renderReport(id,title,payload){const el=$(id);if(!el)return;const rows=reportRows(payload);const obj=payload?.summary||payload?.counts||payload||{};const fallback=Object.entries(obj).filter(([,v])=>typeof v==='number').slice(0,8);const entries=rows.length?rows.map(r=>[r.label||r.name||r.stage||r.outcome||r.type||'Metric',r.count??r.total??r.value??0]):fallback;if(!entries.length){el.innerHTML=`<div class="panel-heading"><h3>${title}</h3></div><p class="crm-empty">No ${title.toLowerCase()} data returned.</p>`;return;}el.innerHTML=`<div class="panel-heading"><div><p class="eyebrow">REPORT</p><h3>${title}</h3></div><span class="small-label">API summary</span></div><div class="report-rows">${entries.map(([label,value])=>`<div><span>${esc(crmLabel(label))}</span><strong>${esc(value)}</strong></div>`).join('')}</div><p class="report-note">Tenant-scoped API results. Loading, unavailable, and empty are distinct states.</p>`;}
|
||||
async function loadReports(){[['pipelineReport','pipeline report'],['outcomesReport','outcomes report'],['activityReport','activity report']].forEach(([id,title])=>{const el=$(id);if(el)el.innerHTML=`<div class="detail-loading">Loading ${title}…</div>`;});try{const [pipeline,outcomes,activity]=await Promise.all([jsonRequest('/api/v1/reports/pipeline'),jsonRequest('/api/v1/reports/outcomes'),jsonRequest('/api/v1/reports/activity')]);renderReport('pipelineReport','Pipeline',pipeline);renderReport('outcomesReport','Outcomes',outcomes);renderReport('activityReport','Activity',activity);}catch(error){if(error.message!=='unauthorized')[['pipelineReport','pipeline'],['outcomesReport','outcomes'],['activityReport','activity']].forEach(([id,label])=>{const el=$(id);if(el)el.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load ${label} report</strong><p>${esc(error.message)}</p></div>`;});}}
|
||||
|
||||
const jobStatuses = ['queued','running','succeeded','failed','cancelled'];
|
||||
const jobRole = () => String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase();
|
||||
const canManageJobs = () => ['admin','owner','operator','manager'].includes(jobRole()) || Boolean(currentUser?.permissions?.includes?.('jobs:manage'));
|
||||
@@ -207,10 +229,11 @@
|
||||
function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='<span>⊞</span><p>No data rows found</p>';return;}const h=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`<table><thead><tr>${h.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${h.map(x=>`<td>${esc(r[x])}</td>`).join('')}</tr>`).join('')}</tbody></table><small class="muted">Showing up to 10 rows · Preview only; nothing added yet.</small>`;}
|
||||
async function login(event){event.preventDefault();const form=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(new FormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});const body=await res.json().catch(()=>({}));if(!res.ok)throw new Error(body.error||'Invalid email or password.');await bootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}}
|
||||
async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}}
|
||||
async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();await loadSources();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
|
||||
async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();await loadSources();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();await loadCrmPipeline();await loadReports();await loadSuppressions();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
|
||||
document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);});
|
||||
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);});
|
||||
$('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('startDemoJobBtn').addEventListener('click',startDemoJob);$('sourcesRefreshBtn').addEventListener('click',loadSources);$('sourceForm').addEventListener('submit',saveSource);$('sourceType').addEventListener('change',e=>{$('sourceCsvField').hidden=e.target.value!=='csv';});$('discoveryForm').addEventListener('submit',e=>{e.preventDefault();runDiscovery(true);});$('discoveryRunBtn').addEventListener('click',()=>runDiscovery(false));$('sourcesList').addEventListener('click',e=>{const button=e.target.closest?.('[data-source-action]');if(button)sourceAction(button.dataset.sourceId,button.dataset.sourceAction);});$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView()));
|
||||
$('savedFilterForm').addEventListener('submit',saveCurrentFilters);$('savedFilterSelect').addEventListener('change',e=>{ $('deleteSavedFilterBtn').disabled=!e.target.value;if(e.target.value)loadSavedFilter(e.target.value);});$('deleteSavedFilterBtn').addEventListener('click',deleteSavedFilter);$('reviewQueueState').addEventListener('change',e=>{const input=e.target.closest?.('[data-review-id]');if(input){if(input.checked)selectedReviewIds.add(String(input.dataset.reviewId));else selectedReviewIds.delete(String(input.dataset.reviewId));updateBulkState();}});$('selectAllReview').addEventListener('change',e=>{reviewQueue.forEach(p=>e.target.checked?selectedReviewIds.add(String(p.id)):selectedReviewIds.delete(String(p.id)));renderReviewQueue({items:reviewQueue,count:$('reviewQueueCount').textContent});});$('bulkVerifyBtn').addEventListener('click',()=>bulkReview('verify'));$('bulkRejectBtn').addEventListener('click',()=>bulkReview('reject'));$('reviewQueueState').addEventListener('click',e=>{if(e.target.id==='retryReviewQueueBtn')loadReviewQueue();});document.querySelectorAll('[data-dashboard-filter]').forEach(card=>card.addEventListener('click',()=>{const kind=card.dataset.dashboardFilter;if(kind==='review'||kind==='suppressed')applyFilters({status:kind});else if(kind==='high')applyFilters({score:'high'});else if(kind==='fresh')applyFilters({});else applyFilters({status:'all',score:'all'});}));
|
||||
$('interactionForm').addEventListener('submit',saveInteraction);$('suppressionForm').addEventListener('submit',addSuppression);$('crmRefreshBtn').addEventListener('click',()=>{loadCrmPipeline();loadInteractions(selectedId);});$('reportsRefreshBtn').addEventListener('click',loadReports);$('suppressionRefreshBtn').addEventListener('click',loadSuppressions);$('pipelineViewToggle').addEventListener('click',()=>{crmListMode=!crmListMode;$('pipelineViewToggle').textContent=crmListMode?'▦ Board view':'☷ List view';$('pipelineViewToggle').setAttribute('aria-pressed',String(crmListMode));renderPipeline();});$('pipelineBoard').addEventListener('click',e=>{const selectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}const stageButton=e.target.closest?.('[data-crm-save-stage]');if(stageButton)saveCrmStage(stageButton.dataset.crmSaveStage);if(e.target.id==='retryCrmBtn')loadCrmPipeline();if(e.target.id==='retryInteractionsBtn'&&selectedId)loadInteractions(selectedId);});$('suppressionState').addEventListener('change',e=>{const input=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));else selectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{const button=e.target.closest?.('[data-remove-suppression]');if(button)removeSuppression(button.dataset.removeSuppression);if(e.target.id==='retrySuppressionsBtn')loadSuppressions();});$('selectAllSuppressions').addEventListener('change',e=>{suppressions.forEach(item=>e.target.checked?selectedSuppressionIds.add(String(item.id)):selectedSuppressionIds.delete(String(item.id)));renderSuppressions();});$('bulkReviewSuppressionsBtn').addEventListener('click',bulkReviewSuppressions);
|
||||
bootstrap();
|
||||
})();
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
<a class="nav-item" href="#add"><span>+</span> Add prospects</a>
|
||||
<a class="nav-item" href="#jobs" data-nav="jobs"><span>◷</span> Jobs</a>
|
||||
<a class="nav-item" href="#sources" data-nav="sources"><span>⌁</span> Sources</a>
|
||||
<a class="nav-item" href="#crmPipeline" data-nav="crm"><span>◫</span> CRM pipeline</a>
|
||||
<a class="nav-item" href="#crmReports" data-nav="reports"><span>▤</span> Reports</a>
|
||||
<a class="nav-item" href="#suppressionCenter" data-nav="suppression"><span>⊘</span> Suppressions</a>
|
||||
<a class="nav-item" href="#scoreRules" data-nav="score-rules"><span>◈</span> Score rules</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot"><div class="live-dot"></div><div><strong>Workspace live</strong><small>Data sync is healthy</small></div></div>
|
||||
@@ -86,6 +89,25 @@
|
||||
</section>
|
||||
<section class="lower-grid" id="add"><article class="panel add-panel"><div class="panel-heading"><div><p class="eyebrow">INTAKE</p><h2>Add a prospect</h2></div><span class="small-label">Manual entry</span></div><form id="addForm"><div class="form-grid"><label>Company name<input required name="name" placeholder="Acme Inc."></label><label>Website <span class="optional">optional</span><input name="website" type="url" placeholder="https://acme.com"></label><label>Location<input name="location" placeholder="Cape Town, ZA"></label><label>Notes <span class="optional">optional</span><input name="description" placeholder="Why this is a fit…"></label></div><div class="form-footer"><p id="formMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Add to review queue</button></div></form></article>
|
||||
<article class="panel csv-panel"><div class="panel-heading"><div><p class="eyebrow">BULK INTAKE</p><h2>CSV preview</h2></div><label class="button ghost upload-label" for="csvInput">↑ Choose CSV</label><input id="csvInput" type="file" accept=".csv,text/csv" hidden></div><p class="muted">Preview rows before adding them to your review queue.</p><div id="csvPreview" class="csv-empty"><span>⊞</span><p>No file selected</p><small>CSV stays in your browser until you confirm.</small></div></article></section>
|
||||
<section class="crm-section" id="crmPipeline" aria-labelledby="crmPipelineTitle" data-smoke="crm-pipeline">
|
||||
<div class="crm-header panel"><div><p class="eyebrow">CRM WORKFLOW</p><h2 id="crmPipelineTitle">Pipeline board</h2><p class="muted">Move prospects through explicit human stages. Stage changes never send outreach.</p></div><div class="crm-actions"><button class="button ghost" id="crmRefreshBtn" type="button">↻ Refresh CRM</button><button class="button ghost" id="pipelineViewToggle" type="button" aria-pressed="false">☷ List view</button></div></div>
|
||||
<div class="crm-safety" role="note"><strong>No outreach from this workspace.</strong> Pipeline actions, interactions, and follow-ups are internal CRM records only. Suppressed or unreviewed prospects remain unavailable for contact.</div>
|
||||
<p id="crmMessage" class="crm-message" role="status" aria-live="polite"></p>
|
||||
<div id="pipelineBoard" class="pipeline-board" aria-live="polite"><div class="detail-loading">Sign in to load the pipeline.</div></div>
|
||||
</section>
|
||||
<section class="crm-section" id="crmActivity" aria-labelledby="crmActivityTitle" data-smoke="crm-interactions">
|
||||
<div class="crm-header panel"><div><p class="eyebrow">RELATIONSHIP HISTORY</p><h2 id="crmActivityTitle">Interactions & follow-ups</h2><p class="muted">Capture outcomes and next steps without contacting anyone.</p></div></div>
|
||||
<div class="crm-two-col"><article class="panel interaction-panel"><div id="interactionState" class="detail-loading">Select a prospect to load interactions.</div></article><article class="panel"><div class="panel-heading"><div><p class="eyebrow">ADD RECORD</p><h3>Log an interaction</h3></div><span class="small-label">Internal only</span></div><form id="interactionForm" class="crm-form"><label>Type<select name="type"><option value="note">Note</option><option value="call">Call</option><option value="meeting">Meeting</option><option value="email">Email (record only)</option></select></label><label>Outcome<select name="outcome"><option value="">Choose outcome</option><option value="no_response">No response</option><option value="interested">Interested</option><option value="not_a_fit">Not a fit</option><option value="follow_up">Follow-up requested</option></select></label><label>Follow-up date <span class="optional">optional</span><input name="follow_up_at" type="date"></label><label>Summary<textarea name="summary" rows="4" required placeholder="What happened? Keep this an internal record."></textarea></label><p id="interactionMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Save interaction</button></form></article></div>
|
||||
</section>
|
||||
<section class="crm-section" id="crmReports" aria-labelledby="crmReportsTitle" data-smoke="crm-reports">
|
||||
<div class="crm-header panel"><div><p class="eyebrow">REPORTING</p><h2 id="crmReportsTitle">CRM reports</h2><p class="muted">Tenant-scoped pipeline, outcomes, and activity summaries from the API.</p></div><button class="button ghost" id="reportsRefreshBtn" type="button">↻ Refresh reports</button></div>
|
||||
<div class="reports-grid"><article class="panel report-panel" id="pipelineReport"><div class="detail-loading">Loading pipeline report…</div></article><article class="panel report-panel" id="outcomesReport"><div class="detail-loading">Loading outcomes report…</div></article><article class="panel report-panel" id="activityReport"><div class="detail-loading">Loading activity report…</div></article></div>
|
||||
</section>
|
||||
<section class="crm-section" id="suppressionCenter" aria-labelledby="suppressionTitle" data-smoke="suppression-center">
|
||||
<div class="crm-header panel"><div><p class="eyebrow">SAFETY CENTER</p><h2 id="suppressionTitle">Suppression center</h2><p class="muted">Review and maintain tenant-scoped do-not-contact rules.</p></div><button class="button ghost" id="suppressionRefreshBtn" type="button">↻ Refresh suppressions</button></div>
|
||||
<div class="suppression-warning" role="alert"><strong>Suppression always wins.</strong> Suppressed contacts and domains cannot be contacted, regardless of score, stage, or interaction outcome.</div>
|
||||
<div class="crm-two-col"><article class="panel"><div class="panel-heading"><div><p class="eyebrow">ADD RULE</p><h3>Add suppression</h3></div><span class="small-label">Explicit confirmation required</span></div><form id="suppressionForm" class="crm-form"><label>Kind<select name="kind"><option value="email">Email</option><option value="domain">Domain</option><option value="phone">Phone</option></select></label><label>Value<input name="value" required placeholder="person@example.com"></label><label>Reason <span class="optional">optional</span><input name="reason" placeholder="Customer request / policy"></label><p id="suppressionMessage" class="form-message" role="status"></p><button class="button danger" type="submit">Add suppression</button></form></article><article class="panel"><div class="panel-heading"><div><p class="eyebrow">REGISTER</p><h3>Current suppressions</h3></div><div class="suppression-bulk-actions"><label class="checkbox-label"><input id="selectAllSuppressions" type="checkbox"> Select all</label><button class="button ghost compact" id="bulkReviewSuppressionsBtn" type="button" disabled>Review selected</button></div></div><div id="suppressionState" class="detail-loading">Sign in to load suppressions.</div></article></div>
|
||||
</section>
|
||||
<footer><span>ProspectOS</span><span>Evidence-led prospecting · <a href="#explorer">Review queue</a></span></footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -53,5 +53,10 @@ frame.onload=async()=>{const d=frame.contentDocument;const js=await fetch('app.j
|
||||
,['Phase 11 authenticated saved-view and bulk-review contracts',()=>js.includes('/api/v1/saved-filters')&&js.includes('/api/v1/review-queue')&&js.includes('/api/v1/businesses/bulk-review')&&js.includes('jsonRequest')&&js.includes('window.confirm')]
|
||||
,['Phase 11 bounded selection and safe labels',()=>js.includes('slice(0,100)')&&js.includes('selected visible')&&js.includes('no outreach will be sent')&&d.querySelector('#reviewQueueCount')]
|
||||
,['Phase 11 dashboard cards link to filters and responsive styles',()=>d.querySelectorAll('[data-dashboard-filter]').length>=5&&js.includes('applyFilters')&&js.includes('metricSuppressed')&&js.includes('saved-view-controls')&&js.includes('@media')]
|
||||
,['Phase 12 CRM pipeline board/list and explicit transitions',()=>!!d.querySelector('[data-smoke="crm-pipeline"]')&&!!d.querySelector('#pipelineBoard')&&!!d.querySelector('#pipelineViewToggle')&&js.includes('/api/v1/pipeline-entries')&&js.includes('Stage transition saved')&&js.includes('outreach:false')&&js.includes('window.confirm')]
|
||||
,['Phase 12 interaction timeline and metadata form',()=>!!d.querySelector('[data-smoke="crm-interactions"]')&&!!d.querySelector('#interactionForm')&&!!d.querySelector('#interactionState')&&js.includes('/api/v1/interactions?business_id=')&&js.includes('follow_up_at')&&js.includes('outcome')&&js.includes('No message was sent')]
|
||||
,['Phase 12 reports panels and API states',()=>!!d.querySelector('[data-smoke="crm-reports"]')&&['pipelineReport','outcomesReport','activityReport'].every(id=>!!d.querySelector('#'+id))&&['/api/v1/reports/pipeline','/api/v1/reports/outcomes','/api/v1/reports/activity'].every(path=>js.includes(path))&&js.includes('No')&&js.includes('Unable to load')]
|
||||
,['Phase 12 suppression center controls and safety',()=>!!d.querySelector('[data-smoke="suppression-center"]')&&!!d.querySelector('#suppressionForm')&&!!d.querySelector('#bulkReviewSuppressionsBtn')&&js.includes('/api/v1/suppressions')&&js.includes('data-remove-suppression')&&js.includes('Suppression always wins')&&js.includes('do-not-contact')]
|
||||
,['Phase 12 CRM responsive styles and authenticated requests',()=>js.includes('credentials:\'include\'')&&js.includes('crm-two-col')&&js.includes('reports-grid')&&js.includes('pipeline-board')&&js.includes('@media')]
|
||||
];let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test();if(ok)passed++;return `<li class="${ok?'pass':'fail'}">${ok?'PASS':'FAIL'} — ${name}</li>`}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;};
|
||||
</script>
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
.website-scan-actions .button{min-height:32px}.contact-extraction-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.contact-extraction-heading h4{margin:.1rem 0}.contact-extraction-actions{display:flex;gap:7px;flex-wrap:wrap}.contact-extraction-safety{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:10px 0}.extracted-contact-list{display:grid;gap:9px}.extracted-contact{border:1px solid var(--line);border-radius:9px;padding:11px;background:#fff}.extracted-contact-head{display:flex;justify-content:space-between;gap:10px;align-items:start}.extracted-contact-head strong{overflow-wrap:anywhere}.contact-confidence{color:var(--violet);font-size:11px;font-weight:700;white-space:nowrap}.extracted-contact-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;margin:10px 0 0}.extracted-contact-facts div{border:1px solid var(--line);border-radius:7px;padding:7px}.extracted-contact-facts dt{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}.extracted-contact-facts dd{margin:3px 0 0;font-size:12px;overflow-wrap:anywhere}.extracted-contact-facts a{color:var(--violet)}.contact-source{grid-column:1 / -1}.suppression-status{color:var(--green)}.contact-extraction-empty{padding:18px 4px}.contact-extraction-empty strong{color:var(--muted)}@media(max-width:700px){.website-scan-heading{flex-direction:column}.website-scan-actions{width:100%}.website-scan-actions .button{flex:1}.website-scan-grid{grid-template-columns:1fr}.signal-list{grid-template-columns:repeat(2,minmax(0,1fr))}.contact-extraction-heading{flex-direction:column}.contact-extraction-actions{width:100%}.contact-extraction-actions .button{flex:1}.extracted-contact-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.contact-source{grid-column:1 / -1}}
|
||||
.metric-link{color:inherit;text-decoration:none}.metric-link:hover{border-color:#c9c3ff;transform:translateY(-1px)}.saved-view-controls{display:grid;grid-template-columns:minmax(220px,1fr) 180px auto;gap:8px;align-items:center;padding:12px 0;border-top:1px solid var(--line)}.saved-view-controls .form-message{grid-column:1 / -1;margin:0}.saved-view-controls input,.saved-view-controls select{border:1px solid var(--line);border-radius:7px;padding:8px;font:inherit;min-width:0}.review-queue{margin:4px 0 14px;padding:14px;background:#fbfbff;border:1px solid var(--line);border-radius:10px}.queue-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.queue-heading h3{margin:.1rem 0}.queue-state{margin-top:8px}.queue-list{display:grid;gap:6px;max-height:300px;overflow:auto}.queue-row{display:flex;align-items:center;gap:10px;padding:8px;border:1px solid var(--line);border-radius:7px;background:#fff;cursor:pointer}.queue-row input{flex:0 0 auto}.queue-row>span:nth-child(2){display:flex;flex-direction:column;min-width:0;flex:1}.queue-row small{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.queue-boundary{font-size:11px;color:var(--muted);margin:8px 0 0}.bulk-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)}.bulk-actions .checkbox-label{margin:0}.explorer-state{min-height:0;color:var(--green);font-size:12px;padding:4px 0}.explorer-state.error{color:var(--red)}
|
||||
.status.review{background:var(--amber-soft);color:var(--amber)}
|
||||
.crm-section{margin-top:28px;scroll-margin-top:24px}.crm-header{display:flex;align-items:flex-start;justify-content:space-between;gap:18px}.crm-header h2{margin:.15rem 0 .25rem}.crm-actions{display:flex;gap:8px;flex-wrap:wrap}.crm-safety,.suppression-warning{margin:14px 0;padding:12px 15px;border:1px solid #dcd8ff;border-radius:9px;background:var(--violet-soft);color:#5145a7}.suppression-warning{border-color:#f1d7a5;background:var(--amber-soft);color:#76500d}.crm-message{min-height:22px;color:var(--green);padding:6px 2px}.crm-message.error{color:var(--red)}.pipeline-board{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:12px;overflow-x:auto;align-items:start}.pipeline-column{background:#f1f2f8;border:1px solid var(--line);border-radius:10px;padding:10px;min-height:180px}.pipeline-column-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.pipeline-column-head h3{margin:0;font-size:13px}.pipeline-card{background:#fff;border:1px solid var(--line);border-radius:9px;padding:10px;margin:8px 0;box-shadow:var(--shadow)}.pipeline-card.is-suppressed{border-color:#e9b8bd;background:#fffafa}.pipeline-card-link{display:grid;gap:3px;width:100%;border:0;background:none;text-align:left;color:inherit;padding:0;cursor:pointer}.pipeline-card-link small,.pipeline-card-link .score{font-size:11px;color:var(--muted)}.pipeline-card-actions{display:flex;gap:6px;margin-top:9px}.pipeline-card-actions select{min-width:0;flex:1;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;font-size:12px}.pipeline-list-view{display:block}.pipeline-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}.crm-column-empty,.crm-empty{color:var(--muted);font-size:12px;text-align:center;padding:20px 8px}.suppression-inline,.review-inline{font-size:11px;color:var(--red);margin:8px 0 0}.review-inline{color:var(--amber)}.crm-two-col{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(300px,.9fr);gap:18px}.crm-form{display:grid;gap:10px}.crm-form label{display:grid;gap:5px;font-size:12px;font-weight:650}.crm-form input,.crm-form select,.crm-form textarea{border:1px solid var(--line);border-radius:7px;padding:9px;font:inherit;font-weight:400}.crm-form textarea{resize:vertical}.crm-timeline{list-style:none;padding:0;margin:12px 0}.crm-timeline li{display:flex;gap:10px;border-top:1px solid var(--line);padding:12px 0}.crm-timeline li>div{display:grid;gap:4px;min-width:0}.crm-timeline p{margin:0;color:var(--muted)}.crm-timeline small{color:var(--muted);font-size:11px}.timeline-dot{width:9px;height:9px;flex:0 0 9px;margin-top:6px;border-radius:50%;background:var(--violet);box-shadow:0 0 0 4px var(--violet-soft)}.outcome-chip{font-size:11px;color:var(--violet);background:var(--violet-soft);border-radius:999px;padding:2px 7px;width:max-content}.reports-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.report-rows{display:grid;gap:8px}.report-rows div{display:flex;justify-content:space-between;gap:10px;border-top:1px solid var(--line);padding:9px 0}.report-rows strong{color:var(--violet)}.report-note{font-size:11px;color:var(--muted)}.suppression-bulk-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.suppression-bulk-actions .checkbox-label{margin:0}.suppression-list{display:grid}.suppression-row{display:flex;justify-content:space-between;gap:10px;align-items:center;border-top:1px solid var(--line);padding:11px 0}.suppression-row .checkbox-label{margin:0;flex:1}.suppression-row .checkbox-label span{display:grid;gap:2px;min-width:0}.suppression-row small{color:var(--muted);overflow-wrap:anywhere}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:1000px){.pipeline-board{grid-template-columns:repeat(4,minmax(220px,1fr))}.reports-grid{grid-template-columns:1fr 1fr}}@media(max-width:700px){.crm-header{flex-direction:column}.crm-actions,.crm-actions .button{width:100%}.crm-actions .button{flex:1}.crm-two-col,.reports-grid{grid-template-columns:1fr}.pipeline-board{grid-template-columns:repeat(4,minmax(235px,1fr))}.pipeline-list{grid-template-columns:1fr}.suppression-row{align-items:flex-start}.suppression-bulk-actions{width:100%}}
|
||||
@media(max-width:700px){.saved-view-controls{grid-template-columns:1fr}.saved-view-controls .inline-form{display:flex}.saved-view-controls .inline-form input{flex:1}.queue-row{align-items:flex-start}.bulk-actions .button{flex:1}.metric-link{min-width:0}}
|
||||
.score-overview{margin-top:18px}.score-distribution-panel{min-width:0}.score-rules-section{margin-top:18px;scroll-margin-top:24px}.score-breakdown-panel{background:#fbfbff;border-radius:10px;padding:15px}.score-panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.score-panel-heading h4{margin:.1rem 0}.score-breakdown-summary{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:8px;margin:12px 0}.score-breakdown-summary>div,.score-meta div{border:1px solid var(--line);border-radius:8px;background:#fff;padding:10px}.score-breakdown-summary small,.score-breakdown-summary b{display:block}.score-breakdown-summary small,.score-meta dt{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.05em}.score-breakdown-summary b{margin-top:4px}.score-total{display:block;font-size:28px;line-height:1.1;margin-top:3px}.score-total small{display:inline;font-size:12px;margin-left:3px}.score-meta{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:0}.score-meta dd{margin:3px 0 0}.score-rules-explanation{margin-top:13px}.score-rules-explanation h5{margin:0 0 7px}.score-rule-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;border-top:1px solid var(--line);padding:9px 0}.score-rule-row span{font-weight:650}.score-rule-row strong{color:var(--violet);font-size:12px}.score-rule-row small{grid-column:1 / -1;color:var(--muted)}.score-safety,.score-config-note{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:12px 0 0}.score-rule-table{border-top:1px solid var(--line);margin-top:12px}.score-config-row{display:grid;grid-template-columns:minmax(140px,1.4fr) .8fr .7fr .7fr auto;gap:10px;align-items:center;padding:11px 0;border-bottom:1px solid var(--line)}.score-config-row span{font-size:12px}.score-config-row span:nth-child(2){color:var(--green);font-weight:700}.score-config-row small{color:var(--muted)}.score-config-row button:disabled{opacity:.7;cursor:not-allowed}.score-distribution-list{display:grid;gap:10px;margin-top:12px}.distribution-row{display:grid;grid-template-columns:minmax(100px,1fr) 42px minmax(100px,2fr);gap:10px;align-items:center}.distribution-row strong{text-align:right}.distribution-track{height:8px;border-radius:99px;background:var(--line);overflow:hidden}.distribution-track i{display:block;height:100%;background:var(--violet);border-radius:inherit}@media(max-width:700px){.score-breakdown-summary{grid-template-columns:1fr 1fr}.score-breakdown-summary>div:first-child{grid-column:1 / -1}.score-config-row{grid-template-columns:1fr 1fr}.score-config-row strong{grid-column:1 / -1}.score-config-row button{justify-self:start}.distribution-row{grid-template-columns:minmax(95px,1fr) 34px minmax(80px,1fr)}}
|
||||
|
||||
@@ -109,6 +109,18 @@ For each bulk operation, verify the server-reported selection size and maximum,
|
||||
|
||||
Monitor saved-filter errors, queue count freshness, pagination/cursor failures, cross-tenant denials, suppression/eligibility skips, batch-limit violations, idempotency conflicts, partial bulk failures, audit append/readback failures, and merge snapshot/reversal outcomes. Preserve filter/selection snapshots or safe hashes and bounded totals in operational records, but redact secrets and unnecessary contact data. The current Compose/MVP runtime includes saved-filter creation/listing, a bounded review queue, clickable dashboard metadata, and explicit verify/reject/assign bulk review actions. It remains pilot-only: the remaining Phase 11 limitations are that update/delete saved-filter routes are not wired, queue counts lack complete matching-set/predicate snapshots, bulk operations have no preview/idempotency/per-record result contract, and the audit trail records a batch aggregate. Treat these as release blockers until the API/UI slices are hardened and verified.
|
||||
|
||||
## Phase 12 CRM operations
|
||||
|
||||
Operate CRM as human review and record-keeping, not outbound engagement. Before enabling the slice, verify the tenant/role matrix, canonical transition table (`new` → `contacted` → `qualified` → `proposal` → `negotiation` → `won`/`lost`, plus any explicitly configured paused/disqualified and reopen rules), append-only interaction policy, normalized outcome vocabulary, batch/report limits, suppression source, and retention class. Keep `AUTOMATED_OUTREACH_ENABLED=false` and verify there is no delivery provider, campaign queue, SMTP probe, or follow-up worker.
|
||||
|
||||
For pipeline changes, inspect the server response and audit event, including actor, before/after state, reason, timestamp, and correlation/idempotency ID. Reject direct jumps, edits to history, and actions on merged/inactive records. A same-state retry may be treated as idempotent; a reopen must be a new reasoned event. Adding an interaction does not advance a stage. For outcomes, use only `connected`, `no_answer`, `left_message`, `meeting_booked`, `meeting_held`, `qualified`, `disqualified`, `won`, `lost`, or `other`; preserve `other` as an explicit catch-all and treat `do_not_contact` as a separate immediate hard block.
|
||||
|
||||
Run reports with an explicit bounded date interval, `as_of`, timezone, filter snapshot, and requested metric semantics. Confirm whether totals are latest-state, event-time, distinct-business, page, or matching-set counts. Check freshness and partial/error status before distributing a report; never interpret a page count as a tenant total or a report as authorization. Report and export jobs must be tenant-scoped, idempotent where they have side effects, audited, and redacted.
|
||||
|
||||
Operate the suppression center as the final deny gate. Verify normalized email/domain/phone matching before CRM writes, responses, caches, exports, reports, and any queue. Investigate any record that is not visibly marked **Do not contact** after a match; stop the affected write/report path rather than retrying blindly. Unsuppression/removal requires an authorized reason and audit readback. Retain suppression provenance and history even when the underlying contact is deleted, subject to the approved legal/retention policy.
|
||||
|
||||
Monitor transition rejection and conflict rates, interaction/outcome write and correction failures, unknown outcomes, suppression matches and attempted bypasses, report freshness/partial failures, export denials, idempotency conflicts, cross-tenant denials, audit append/readback failures, and retention/deletion job results. Routine logs must contain no secrets, full contact values, or unnecessary free text. The current Compose/MVP remains pilot-only until durable CRM migrations, retention jobs, reproducible reports, integration tests, and recovery procedures are verified.
|
||||
|
||||
## Configuration and deployment
|
||||
|
||||
Copy `.env.example` for local development. Production values must be supplied by the deployment environment, never committed. `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWORD` are optional API environment variables for first-run admin provisioning only; set them together through a secret store or protected deployment environment, remove them immediately after successful bootstrap, and rotate the password. Do not put real values in Compose files, CI variables visible to logs, images, or committed `.env` files.
|
||||
|
||||
@@ -97,6 +97,19 @@ Phase 10 is not production-ready until rule-set lifecycle permissions/approval,
|
||||
|
||||
Phase 11 is present in the current Compose source with durable saved-filter storage, a bounded review queue, clickable dashboard metadata, and explicit bulk review actions. It is not production-ready: saved-filter update/delete routes are not wired, queue/count responses lack complete predicate and matching-set semantics, bulk actions lack preview/idempotency/per-item outcomes, and audit coverage is aggregate for a batch. Do not infer stronger guarantees from the UI. Before release, add cross-tenant, suppression-precedence, merge-eligibility, count-scope, replay/idempotency, partial-failure, and audit-completeness tests.
|
||||
|
||||
## Phase 12 CRM security controls
|
||||
|
||||
- Treat pipeline state, interaction history, outcomes, reports, exports, and suppressions as tenant data. Enforce `organization_id` on every query, join, cache key, background job, report, and export; cross-tenant identifiers must not disclose existence.
|
||||
- Validate pipeline transitions server-side against the canonical lifecycle `new` → `contacted` → `qualified` → `proposal` → `negotiation` → `won`/`lost`; any paused/disqualified state must be explicitly configured, reasoned, and audited before use or reopening. Reject direct jumps, client-submitted history, edits to historical events, same-record mutations after merge/inactivation, and unaudited state changes. Same-state retries must be idempotent.
|
||||
- Keep interactions append-only and bounded. Record actor, channel, business/contact reference, occurred and recorded times, provenance, safe redacted summary, outcome, and correlation/idempotency lineage. The normalized outcome set is `connected`, `no_answer`, `left_message`, `meeting_booked`, `meeting_held`, `qualified`, `disqualified`, `won`, `lost`, or `other`; `other` must not be treated as success or failure. Corrections append a superseding event and preserve the original; free text is untrusted input and must be size-limited and escaped.
|
||||
- Normalize outcomes into `connected`, `no_answer`, `left_message`, `meeting_booked`, `meeting_held`, `qualified`, `disqualified`, `won`, `lost`, and `other`. Treat `other` as an explicit catch-all, not success or failure. `do_not_contact` is a separate unconditional deny state and cannot be overridden by a later outcome, score, stage, verification, or client payload.
|
||||
- Define report semantics explicitly: bounded date range, timezone, `as_of`, freshness, filter snapshot, latest-state versus event-time aggregation, distinct-business versus event counts, and treatment of suppressed/merged/inactive/unknown records. Page/matching counts are not authorization. Tenant-key report caches and exports, authorize them independently, and redact contact values/free text.
|
||||
- Apply suppression before persistence, response, cache, report eligibility, export, queueing, or any future side effect. Normalize email/domain/phone matching server-side; retain source, reason, scope, actor, and effective timestamps. Unsuppression/removal requires authorization, reason, audit, and re-evaluation. Preserve suppressed records as visible safety state rather than silently deleting them.
|
||||
- Audit every transition, interaction/outcome write or correction, suppression decision/change, report/export request and result, including before/after or bounded result, actor/tenant, time, policy/version, correlation/idempotency ID, and safe reason. Protect audit history from ordinary edits and apply explicit retention, deletion, and legal-hold rules.
|
||||
- Outreach remains prohibited: no send endpoint, SMTP probing, validation mail, campaign, delivery scheduler, automated follow-up, or consent inference. Any future outreach requires separate product/legal/security approval, deny-by-default configuration, rate/abuse controls, suppression re-checks, and independent audit.
|
||||
|
||||
Phase 12 is not production-ready until transition and outcome invariants, suppression precedence at every boundary, report reproducibility/timezone semantics, export authorization, retention/deletion, idempotent retry, and cross-tenant isolation are covered by integration tests and operational monitoring.
|
||||
|
||||
## Known limitations before production
|
||||
|
||||
1. **Password storage:** production passwords must be hashed with Argon2id using a reviewed cost/memory/parallelism policy. Never store plaintext or reversible passwords, and never log bootstrap credentials. Rehash on login when the policy changes.
|
||||
|
||||
Reference in New Issue
Block a user