add operator review workflow

This commit is contained in:
Marco0300
2026-09-03 11:57:18 +02:00
parent 655780ff88
commit de97a2337d
12 changed files with 318 additions and 15 deletions
+12
View File
@@ -142,6 +142,18 @@ Recalculation is an explicit, tenant-scoped operation. It must snapshot the inpu
Phase 10 remains a pilot boundary unless the runtime exposes all of the above controls end to end. Production work includes administrative rule-set lifecycle/approval, immutable calculation inputs, deterministic rounding/tie-breaking, scheduled recalculation with leases, retention and export semantics for explanations/audit, and regression tests proving suppression, stale, uncertain, and cross-tenant isolation behavior. See the API, security, and operations contracts for the authoritative safeguards.
## Phase 11 dashboard and review workflow boundary
Phase 11 defines the operator-facing review workflow around **saved filters**, a tenant-scoped **review queue**, and deliberately bounded bulk actions. A saved filter is a named, server-persisted filter definition (including search, score/status/pipeline/eligibility criteria, sort, and page-size preferences) owned by one tenant. Save, load, rename, and delete operations must authorize against the authenticated tenant; filter definitions are configuration, not authorization, and must not contain secrets or unbounded query instructions.
The review queue is a derived view of the current tenant's matching businesses. Queue counts and rows must use the same filter predicate, exclude records the caller cannot read, and make suppression and merge state explicit. Suppressed/do-not-contact records are never eligible for contact or bulk contact action; merged/non-active records are not merge-eligible. Match suggestions remain review-only: a score cannot auto-merge, and every merge requires an explicit permitted human confirmation and a reversible, tenant-scoped snapshot.
Bulk actions must be bounded by a server-enforced maximum, an explicit set of tenant-scoped record IDs or an immutable filter snapshot, and a preview/confirmation step. The API must re-check authorization, suppression, merge eligibility, and current record state at execution time, use an idempotency key or equivalent safe retry control, and return per-record outcomes rather than claiming that the whole selection succeeded. A filtered count is not permission to act on an unbounded result set, and bulk actions must never imply outreach or bypass review.
Dashboard counts are clickable only when their scope and query semantics are clear. A click must carry the exact tenant-scoped predicate that produced the count; it must not silently broaden to all records. Labels must distinguish the current page from the full matching set, and loading, stale, unavailable, and error states must not be rendered as zero. Every saved-filter mutation, queue decision, bulk preview/confirmation/result, suppression decision, and merge/reversal must emit an audit event with tenant, actor, action, timestamp, filter/selection snapshot, bounded counts, per-item outcomes, and relevant policy/version identifiers. Audit details must be redacted and tenant-scoped.
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.
## Verification
```bash
+12
View File
@@ -150,6 +150,18 @@ Recalculation must be an explicit authenticated operation, preferably represente
The current MVP's scoring surface is limited compared with the Phase 10 contract: production still needs an authorized rule-set management API, approval/activation and rollback semantics, immutable evidence snapshots, scheduled/durable recalculation, concurrency protection, deterministic migration of old scores, and comprehensive tests for suppression, stale/uncertain evidence, audit completeness, and tenant isolation.
## Phase 11 dashboard and review workflow contract
Phase 11 adds the API contract for saved filters and review operations without weakening the tenant boundary. A saved filter is a named, tenant-owned record containing a validated, bounded predicate (search, score/status/pipeline/eligibility filters, sort, and page-size preference). Save/load/update/delete/list routes must scope by `organization_id`, reject unknown or unbounded fields, and never treat a client-provided filter ID as authorization. Sharing, if added, must be explicit and remain within the tenant; filter definitions must not store secrets.
A review queue is a derived, tenant-scoped projection of businesses matching the saved/current filter. Its response must identify the predicate/snapshot, ordering, page or cursor, bounded `items`, and whether counts are page counts or full matching-set counts. Suppressed/do-not-contact records must remain visible as safety state when policy requires review, but are never contact-eligible. Merged/non-active businesses are excluded from merge candidates and must not be acted on as active records. Queue counts are not authorization and must be recomputed under the caller's tenant and permission scope.
Bulk operations must accept only a bounded selection of IDs or a server-created immutable filter snapshot, enforce a maximum batch size before execution, and require preview followed by explicit confirmation. At execution time the server must re-check tenant ownership, permissions, suppression, active/merge eligibility, and current versions. Require an idempotency key or equivalent safe retry behavior, prevent duplicate side effects, and return a per-record result (`succeeded`, `skipped`, or `failed` with a safe reason) plus bounded totals. A request accepted or previewed is not completion. Bulk review actions do not create an outreach capability and must not auto-merge records.
Clickable dashboard counts must link to the exact tenant-scoped predicate that produced them. The API must distinguish `page_count` from `matching_count`/`has_more`; clients must not turn a page count into a global total or silently drop eligibility/suppression criteria on navigation. Loading, stale, error, and unavailable counts are distinct from zero. Saved-filter changes, queue decisions, bulk preview/confirmation/execution, suppression/eligibility decisions, and merge/reversal operations require audit records containing tenant, actor, action, timestamp, filter/selection snapshot or hash, bounded counts, per-item outcomes, policy/version context, and a correlation/idempotency identifier. Audit reads use the same organization predicate and redact secrets and unnecessary personal/contact data.
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.
## 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.
+154 -3
View File
@@ -55,7 +55,7 @@ def connect(db_path: str) -> sqlite3.Connection:
db = sqlite3.connect(db_path); db.row_factory = sqlite3.Row; db.execute("PRAGMA foreign_keys = ON"); db.executescript(SCHEMA.read_text())
# Upgrade databases created by Phase 1/2 without destroying data.
cols = {r[1] for r in db.execute("PRAGMA table_info(businesses)")}
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")):
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")
db.execute("INSERT OR IGNORE INTO organizations (id,name) VALUES (?,?)", (ORGANIZATION_ID, "Demo organization"))
@@ -111,6 +111,117 @@ class ApiHandler(BaseHTTPRequestHandler):
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
def _bounded_filters(self, value):
def walk(item, depth=0):
if depth > 4: raise ValueError("filters_too_deep")
if isinstance(item, dict):
if len(item) > 30: raise ValueError("filters_too_large")
return {str(k)[:80]: walk(v, depth + 1) for k, v in item.items()}
if isinstance(item, list):
if len(item) > 50: raise ValueError("filters_too_large")
return [walk(v, depth + 1) for v in item]
if isinstance(item, str):
if len(item) > 500: raise ValueError("filter_value_too_large")
return item
if item is None or isinstance(item, (bool, int, float)):
return item
raise ValueError("invalid_filters")
if not isinstance(value, dict): raise ValueError("invalid_filters")
result = walk(value)
if len(json.dumps(result, separators=(",", ":"), ensure_ascii=False).encode()) > 8192: raise ValueError("filters_too_large")
return result
def _saved_filter_json(self, row):
item = row_json(row)
try: item["filters"] = json.loads(item.pop("filters_json") or "{}")
except (TypeError, ValueError): item["filters"] = {}
return item
def list_saved_filters(self, db, user):
rows = db.execute("SELECT * FROM saved_filters WHERE organization_id=? AND user_id=? ORDER BY updated_at DESC,id DESC", (user["organization_id"], user["id"])).fetchall()
return self.send_json(200, {"organization_id": user["organization_id"], "items": [self._saved_filter_json(r) for r in rows]})
def save_filter(self, payload, db, user):
name = str(payload.get("name", "")).strip()
raw = payload.get("filters", payload.get("filter", payload.get("filters_json", {})))
if not name or len(name) > 120: return self.send_json(400, {"error": "invalid_saved_filter"})
try: filters = self._bounded_filters(raw)
except ValueError as exc: return self.send_json(400, {"error": str(exc)})
try:
cur = db.execute("INSERT INTO saved_filters(organization_id,user_id,name,filters_json) VALUES(?,?,?,?)", (user["organization_id"], user["id"], name, json.dumps(filters, sort_keys=True, separators=(",", ":"))))
except sqlite3.IntegrityError: return self.send_json(409, {"error": "duplicate_saved_filter"})
self.audit(db, user, "saved_filter.created", str(cur.lastrowid)); db.commit()
return self.send_json(201, self._saved_filter_json(db.execute("SELECT * FROM saved_filters WHERE id=?", (cur.lastrowid,)).fetchone()))
def update_saved_filter(self, fid, payload, db, user):
row = db.execute("SELECT * FROM saved_filters WHERE id=? AND organization_id=? AND user_id=?", (fid, user["organization_id"], user["id"])).fetchone()
if not row: return self.send_json(404, {"error": "not_found"})
fields = []; values = []
if "name" in payload:
name = str(payload["name"]).strip()
if not name or len(name) > 120: return self.send_json(400, {"error": "invalid_saved_filter"})
fields.append("name=?"); values.append(name)
if any(k in payload for k in ("filters", "filter", "filters_json")):
try: filters = self._bounded_filters(payload.get("filters", payload.get("filter", payload.get("filters_json"))))
except ValueError as exc: return self.send_json(400, {"error": str(exc)})
fields.append("filters_json=?"); values.append(json.dumps(filters, sort_keys=True, separators=(",", ":")))
if not fields: return self.send_json(400, {"error": "no_changes"})
values += [fid, user["organization_id"], user["id"]]
try: db.execute("UPDATE saved_filters SET " + ",".join(fields) + ",updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=? AND user_id=?", values)
except sqlite3.IntegrityError: return self.send_json(409, {"error": "duplicate_saved_filter"})
self.audit(db, user, "saved_filter.updated", str(fid)); db.commit()
return self.send_json(200, self._saved_filter_json(db.execute("SELECT * FROM saved_filters WHERE id=?", (fid,)).fetchone()))
def delete_saved_filter(self, fid, db, user):
row = db.execute("SELECT id FROM saved_filters WHERE id=? AND organization_id=? AND user_id=?", (fid, user["organization_id"], user["id"])).fetchone()
if not row: return self.send_json(404, {"error": "not_found"})
db.execute("DELETE FROM saved_filters WHERE id=? AND organization_id=? AND user_id=?", (fid, user["organization_id"], user["id"]))
self.audit(db, user, "saved_filter.deleted", str(fid)); db.commit()
return self.send_json(200, {"ok": True, "id": fid})
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,))])
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
def review_queue(self, db, org, query):
try:
limit = int((query.get("page_size") or [50])[0]); offset = int((query.get("offset") or [0])[0])
if limit < 1 or limit > 100 or offset < 0: raise ValueError
except (ValueError, TypeError): return self.send_json(400, {"error": "invalid_pagination"})
where, params = ["b.organization_id=?"], [org]
status = (query.get("review_status") or query.get("status") or [""])[0].strip()
if status:
if status not in {"pending", "verified", "rejected", "assigned"}: return self.send_json(400, {"error": "invalid_review_status"})
where.append("b.review_status=?"); params.append(status)
for key, op in (("score_min", ">="), ("score_max", "<=")):
raw = (query.get(key) or [""])[0]
if raw:
try: value = int(raw)
except ValueError: return self.send_json(400, {"error": "invalid_score"})
if value < 0 or value > 100: return self.send_json(400, {"error": "invalid_score"})
where.append("b.score" + op + "?"); params.append(value)
priority = (query.get("priority") or query.get("priority_band") or [""])[0].strip()
if priority:
bounds = {"high": (70, 100), "medium": (40, 69), "low": (0, 39)}
if priority not in bounds: return self.send_json(400, {"error": "invalid_priority"})
where += ["b.score BETWEEN ? AND ?"]; params += list(bounds[priority])
website = (query.get("website_state") or query.get("website_class") or [""])[0].strip()
if website: where.append("b.website_class=?"); params.append(website)
freshness = (query.get("freshness_days") or [""])[0]
if freshness:
try: days = int(freshness)
except ValueError: return self.send_json(400, {"error": "invalid_freshness"})
if days < 0 or days > 3650: return self.send_json(400, {"error": "invalid_freshness"})
where.append("b.updated_at >= datetime('now', ?)"); params.append(f"-{days} days")
source_health = (query.get("source_health") or [""])[0].strip()
if source_health:
where.append("EXISTS (SELECT 1 FROM sources s WHERE s.organization_id=b.organization_id AND s.health_status=?)"); params.append(source_health)
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})
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})
@@ -124,7 +235,12 @@ class ApiHandler(BaseHTTPRequestHandler):
if user["role"] not in {"owner","admin"}: return self.send_json(403,{"error":"forbidden"})
return self.send_json(200,{"items":[dict(r) for r in db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id=? ORDER BY id",(org,))]})
if path=="/api/v1/dashboard/summary":
row=db.execute("SELECT COUNT(*) businesses,COALESCE(AVG(score),0) average_score FROM businesses WHERE organization_id=?",(org,)).fetchone(); return self.send_json(200,{"organization_id":org,"businesses":row["businesses"],"average_score":round(row["average_score"],2),"suppressed":db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id=?",(org,)).fetchone()[0]})
row=db.execute("SELECT COUNT(*) businesses,COALESCE(AVG(score),0) average_score FROM businesses WHERE organization_id=?",(org,)).fetchone()
counts={"new":db.execute("SELECT COUNT(*) FROM businesses WHERE organization_id=? AND created_at>=datetime('now','-7 days')",(org,)).fetchone()[0],"hot":db.execute("SELECT COUNT(*) FROM businesses WHERE organization_id=? AND score>=70 AND merge_status='active'",(org,)).fetchone()[0],"review":db.execute("SELECT COUNT(*) FROM businesses WHERE organization_id=? AND review_status='pending'",(org,)).fetchone()[0],"source_health":db.execute("SELECT COUNT(*) FROM sources WHERE organization_id=? AND health_status IN ('healthy','unhealthy')",(org,)).fetchone()[0],"active_jobs":db.execute("SELECT COUNT(*) FROM jobs WHERE organization_id=? AND status IN ('queued','running')",(org,)).fetchone()[0]}
clickable={key:{"count":value,"filter":{"dashboard_filter":key}} for key,value in counts.items()}
return self.send_json(200,{"organization_id":org,"businesses":row["businesses"],"average_score":round(row["average_score"],2),"suppressed":db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id=?",(org,)).fetchone()[0],"counts":counts,"clickable_filters":clickable,"quick_filters":[{"key":key,"count":value,"filter":{"dashboard_filter":key}} for key,value in counts.items()]})
if path=="/api/v1/saved-filters": return self.list_saved_filters(db,user)
if path=="/api/v1/review-queue": return self.review_queue(db,org,parse_qs(parsed.query))
if path=="/api/v1/score-rules": return self.list_score_rules(db,org)
if path=="/api/v1/scoring/summary": return self.scoring_summary(db,org)
if path=="/api/v1/businesses": return self.list_businesses(db,org,parse_qs(parsed.query))
@@ -456,6 +572,27 @@ class ApiHandler(BaseHTTPRequestHandler):
offset=(number("cursor",0) or 0)+(page-1)*size
rows=db.execute("SELECT b.* FROM businesses b WHERE "+" AND ".join(where)+" ORDER BY b.score DESC,b.id LIMIT ? OFFSET ?",params+[size+1,offset]).fetchall(); more=len(rows)>size; rows=rows[:size]
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows],"page":page,"page_size":size,"next_cursor":str(offset+size) if more else None})
def bulk_review(self, payload, db, user):
ids = payload.get("ids", payload.get("business_ids")); action = str(payload.get("action", "")).strip().lower()
if not isinstance(ids, list) or not ids or len(ids) > 100 or any(not isinstance(i, int) or i < 1 for i in ids) or len(set(ids)) != len(ids): return self.send_json(400, {"error": "invalid_bulk_ids"})
if action not in {"verify", "reject", "assign"}: return self.send_json(400, {"error": "invalid_bulk_action"})
if action == "assign":
assignee = str(payload.get("assigned_to", payload.get("assignee", ""))).strip()
if not assignee or len(assignee) > 120: return self.send_json(400, {"error": "assignee_required"})
else: assignee = ""
org = user["organization_id"]; marks = ",".join("?" for _ in ids)
rows = db.execute("SELECT id FROM businesses WHERE organization_id=? AND id IN (" + marks + ")", [org] + ids).fetchall()
if len(rows) != len(ids): return self.send_json(404, {"error": "not_found"})
try:
if action == "verify": db.execute("UPDATE businesses SET verified=1,verified_at=CURRENT_TIMESTAMP,review_status='verified',updated_at=CURRENT_TIMESTAMP WHERE organization_id=? AND id IN (" + marks + ")", [org] + ids)
elif action == "reject": db.execute("UPDATE businesses SET verified=0,review_status='rejected',updated_at=CURRENT_TIMESTAMP WHERE organization_id=? AND id IN (" + marks + ")", [org] + ids)
else: db.execute("UPDATE businesses SET assigned_to=?,review_status='assigned',updated_at=CURRENT_TIMESTAMP WHERE organization_id=? AND id IN (" + marks + ")", [assignee, org] + ids)
self.audit(db, user, "businesses.bulk_review", json.dumps({"action": action, "ids": ids, "assigned_to": assignee}, sort_keys=True))
db.commit()
except Exception:
db.rollback(); raise
return self.send_json(200, {"action": action, "updated": ids, "count": len(ids)})
def do_POST(self):
path=urlparse(self.path).path.rstrip("/")
if path=="/api/v1/auth/login":return self.login(self.read_json())
@@ -472,6 +609,8 @@ class ApiHandler(BaseHTTPRequestHandler):
return self.create_job(self.read_json(),db,user)
if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"})
payload=self.read_json(); org=user["organization_id"]
if path=="/api/v1/saved-filters": return self.save_filter(payload,db,user)
if path=="/api/v1/businesses/bulk-review": return self.bulk_review(payload,db,user)
if path=="/api/v1/score-rules": return self.create_score_rule(payload,db,user)
if len(path.split("/"))==7 and path.split("/")[3:]==["businesses",path.split("/")[4],"score","recalculate"]: return self.recalculate_score(int(path.split("/")[4]) if path.split("/")[4].isdigit() else -1,payload,db,user)
if path.startswith("/api/v1/jobs/"):
@@ -506,11 +645,23 @@ 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","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)
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,self.read_json(),db,user)
return self.send_json(404,{"error":"not_found"})
finally:db.close()
def do_DELETE(self):
path=urlparse(self.path).path.rstrip("/"); db=self.db()
try:
user=self.require_auth(db)
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","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()
def login(self,payload):
db=self.db(); email=str(payload.get("email"," ")).strip().lower(); password=str(payload.get("password","")); user=db.execute("SELECT * FROM users WHERE email=?",(email,)).fetchone()
try:
@@ -558,7 +709,7 @@ class ApiHandler(BaseHTTPRequestHandler):
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()))
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=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",(int(verified),now if verified else None,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))
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"})
+13
View File
@@ -231,3 +231,16 @@ CREATE TABLE IF NOT EXISTS score_history (
);
CREATE INDEX IF NOT EXISTS idx_score_history_business ON score_history(organization_id,business_id,created_at DESC,id DESC);
CREATE INDEX IF NOT EXISTS idx_score_history_org ON score_history(organization_id,created_at DESC,id DESC);
-- Phase 11 operator workflow: saved views and review state.
CREATE TABLE IF NOT EXISTS saved_filters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
filters_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(organization_id,user_id,name)
);
CREATE INDEX IF NOT EXISTS idx_saved_filters_org_user ON saved_filters(organization_id,user_id,updated_at DESC,id DESC);
+52
View File
@@ -0,0 +1,52 @@
import sqlite3
try:
from test_api import ApiSmokeTests
except ModuleNotFoundError:
from tests.test_api import ApiSmokeTests
class OperatorWorkflowTests(ApiSmokeTests):
def test_saved_filter_round_trip_update_delete_and_bounds(self):
status, saved = self.request("POST", "/api/v1/saved-filters", {"name": "Hot", "filters": {"score_min": 70, "website_state": "business_site"}})
self.assertEqual(status, 201)
self.assertEqual(saved["filters"]["score_min"], 70)
fid = saved["id"]
status, listing = self.request("GET", "/api/v1/saved-filters")
self.assertEqual(status, 200)
self.assertEqual(listing["items"][0]["name"], "Hot")
self.assertEqual(self.request("PATCH", f"/api/v1/saved-filters/{fid}", {"filters": {"priority": "high"}})[0], 200)
self.assertEqual(self.request("DELETE", f"/api/v1/saved-filters/{fid}")[0], 200)
self.assertEqual(self.request("POST", "/api/v1/saved-filters", {"name": "Bad", "filters": {"x": ["a"] * 51}})[0], 400)
def test_review_queue_filters_flags_suppression_and_bulk_is_atomic(self):
_, first = self.request("POST", "/api/v1/businesses", {"name": "Queue One", "website": "https://queue-one.test", "email": "one@queue.test"})
_, second = self.request("POST", "/api/v1/businesses", {"name": "Queue Two", "website": "https://queue-two.test"})
self.assertEqual(self.request("POST", "/api/v1/suppressions", {"kind": "domain", "value": "queue-one.test"})[0], 201)
status, queue = self.request("GET", "/api/v1/review-queue?review_status=pending&website_state=business_site&page_size=10")
self.assertEqual(status, 200)
flagged = next(x for x in queue["items"] if x["id"] == first["id"])
self.assertTrue(flagged["suppressed"])
self.assertFalse(flagged["outreach_eligible"])
self.assertEqual(self.request("POST", "/api/v1/businesses/bulk-review", {"ids": [first["id"], second["id"]], "action": "verify"})[0], 200)
status, queue = self.request("GET", "/api/v1/review-queue?review_status=verified&page_size=10")
self.assertEqual(status, 200)
self.assertEqual({x["id"] for x in queue["items"]}, {first["id"], second["id"]})
self.assertFalse(next(x for x in queue["items"] if x["id"] == first["id"])["outreach_eligible"])
self.assertEqual(self.request("POST", "/api/v1/businesses/bulk-review", {"ids": [first["id"]] * 101, "action": "reject"})[0], 400)
db = sqlite3.connect(self.db_path)
self.assertEqual(db.execute("SELECT COUNT(*) FROM audit_log WHERE action='businesses.bulk_review'").fetchone()[0], 1)
db.close()
def test_dashboard_operator_counts_and_clickable_metadata(self):
self.request("POST", "/api/v1/businesses", {"name": "Dashboard New", "website": "https://dash.test"})
status, summary = self.request("GET", "/api/v1/dashboard/summary")
self.assertEqual(status, 200)
self.assertEqual(summary["counts"]["new"], 1)
for key in ("new", "hot", "review", "source_health", "active_jobs"):
self.assertIn(key, summary["clickable_filters"])
self.assertEqual(summary["clickable_filters"][key]["count"], summary["counts"][key])
if __name__ == "__main__":
import unittest
unittest.main()
+12
View File
@@ -84,6 +84,18 @@ If the API exposes recalculation, the UI must show the requested rule-set/versio
Phase 10 remains a pilot display contract until the API supplies stable versioned rule metadata, reproducible input lineage, complete explanation payloads, explicit eligibility reasons, and audited recalculation results. Browser smoke coverage should include score/band disagreement with eligibility, suppression precedence, stale/uncertain rendering, version changes, partial recalculation, and cross-tenant non-disclosure.
## Phase 11 dashboard and review workflow UI contract
Phase 11 is the dashboard/review presentation contract for **saved filters**, a **review queue**, and bounded bulk actions. Saved filters must show a named, human-readable summary of the exact search/status/score/pipeline/eligibility predicate, sort, and page-size settings. Save/load/update/delete controls must reflect API authorization and tenant scope; the browser must not treat a filter ID, hidden field, or local-only copy as permission. If filters are shared, the UI must show that they are tenant-scoped and read-only or editable as returned by the API.
The review queue must make its scope visible: current filter name or predicate, matching-set versus current-page count, ordering, pagination/cursor state, and `has_more`. A row can be selected only from the current tenant-scoped result. Suppressed/do-not-contact state must remain prominent and disable contact-related actions; merged/non-active records are not merge-eligible. Merge suggestions remain **Human review required** and need an explicit confirmation dialog; no score or checkbox may auto-merge.
Bulk actions must show the bounded selection size and server maximum, provide a preview before confirmation, and report per-record success/skipped/failed outcomes. The UI must refresh or reconcile stale rows after execution, preserve suppression and eligibility reasons, and never describe a partial result as complete. A clickable count must navigate using the exact predicate that produced the count; distinguish full matching-set counts from page counts, and show loading/error/unavailable rather than zero. Count cards are navigation affordances, not authorization controls.
The UI must expose audit context for saved-filter changes, queue decisions, bulk preview/confirmation/execution, suppression/eligibility decisions, and merge/reversal: actor, time, bounded selection/filter snapshot, result totals, and safe reason/version metadata. It must not render secrets or unnecessary contact data. The API remains authoritative for tenant isolation, permissions, re-checks, idempotency, suppression precedence, and audit persistence.
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.
## 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.
+28 -7
View File
@@ -4,7 +4,26 @@
const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, '');
const endpoint = (path) => `${API_BASE}${path}`;
let prospects = [], selectedId = null, selectedDetail = null, currentUser = null;
let page = 1, pageSize = 10, hasNextPage = false;
let page = 1, pageSize = 10, hasNextPage = false, savedFilters = [], reviewQueue = [], selectedReviewIds = new Set();
const filterKeys = ['q','score','status','website_class','pipeline_stage','freshness'];
const reviewItems = payload => payloadItems(payload, ['items','businesses','prospects','queue']);
const savedItems = payload => payloadItems(payload, ['items','saved_filters','filters','views']);
const apiError = error => error?.message || 'Request failed.';
function setExplorerState(text='', error=false){const el=$('explorerState');if(el){el.textContent=text;el.className=`explorer-state${error?' error':''}`;}}
function setSavedMessage(text,error=false){const el=$('savedFilterMessage');if(el){el.textContent=text;el.className=`form-message${error?' error':''}`;}}
function currentFilters(){return filterValues();}
function applyFilters(filters={}){filterKeys.forEach(key=>{const id={q:'searchInput',score:'scoreFilter',status:'statusFilter',website_class:'websiteClassFilter',pipeline_stage:'pipelineFilter'}[key];if(filters[key]!==undefined&&$(id))$(id).value=filters[key];});if($('explorerState'))$('explorerState').dataset.filter=filters.freshness||'all';page=1;loadData();}
function selectedReviewProspects(){return [...selectedReviewIds].filter(id=>reviewQueue.some(p=>String(p.id)===String(id)));}
function updateBulkState(){const count=selectedReviewProspects().length; if($('selectedReviewCount'))$('selectedReviewCount').textContent=`${count} selected`;['bulkVerifyBtn','bulkRejectBtn'].forEach(id=>{if($(id))$(id).disabled=!count;});if($('selectAllReview'))$('selectAllReview').checked=Boolean(reviewQueue.length&&count===reviewQueue.length);}
function renderReviewQueue(payload){reviewQueue=reviewItems(payload).slice(0,100);$('reviewQueueCount').textContent=Number(payload?.count??payload?.total??reviewQueue.length);const state=$('reviewQueueState');if(!state)return;if(!reviewQueue.length){state.innerHTML='<p class="muted">No prospects currently require human review.</p>';updateBulkState();return;}state.innerHTML=`<div class="queue-list">${reviewQueue.map(p=>`<label class="queue-row"><input type="checkbox" data-review-id="${esc(p.id)}" ${selectedReviewIds.has(String(p.id))?'checked':''}><span><strong>${esc(p.name||`Prospect ${p.id}`)}</strong><small>${esc(p.website_domain||p.location||'Evidence pending')}</small></span><span class="status review">Needs review</span></label>`).join('')}</div><p class="queue-boundary">Showing up to 100 queued prospects. Bulk actions apply only to selected visible records.</p>`;updateBulkState();}
async function loadReviewQueue(){const state=$('reviewQueueState');if(state)state.innerHTML='<div class="detail-loading" aria-live="polite">Loading review queue…</div>';try{const payload=await jsonRequest('/api/v1/review-queue?page=1&page_size=100');renderReviewQueue(payload);}catch(error){if(error.message!=='unauthorized'&&state)state.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load review queue</strong><p>${esc(apiError(error))}</p><button class="button ghost compact" id="retryReviewQueueBtn" type="button">Try again</button></div>`;}}
function renderSavedFilters(){const select=$('savedFilterSelect');if(!select)return;select.innerHTML='<option value="">Saved views</option>'+savedFilters.map(view=>`<option value="${esc(view.id)}">${esc(view.name||`View ${view.id}`)}</option>`).join('');$('deleteSavedFilterBtn').disabled=true;}
async function loadSavedFilters(){try{savedFilters=savedItems(await jsonRequest('/api/v1/saved-filters'));renderSavedFilters();}catch(error){if(error.message!=='unauthorized')setSavedMessage(`Unable to load saved views: ${apiError(error)}`,true);}}
async function saveCurrentFilters(event){event.preventDefault();const form=event.currentTarget,name=$('savedFilterName').value.trim();if(!name){setSavedMessage('Enter a name for this saved view.',true);return;}try{await jsonRequest('/api/v1/saved-filters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,filters:currentFilters()})});form.reset();setSavedMessage('Saved view created.');await loadSavedFilters();}catch(error){if(error.message!=='unauthorized')setSavedMessage(apiError(error),true);}}
async function loadSavedFilter(id){const view=savedFilters.find(item=>String(item.id)===String(id));if(view)applyFilters(view.filters||view.filter||view.filters_json||{});}
async function deleteSavedFilter(){const id=$('savedFilterSelect').value;if(!id||!window.confirm('Delete this saved view?'))return;try{await jsonRequest(`/api/v1/saved-filters/${encodeURIComponent(id)}`,{method:'DELETE'});setSavedMessage('Saved view deleted.');await loadSavedFilters();}catch(error){if(error.message!=='unauthorized')setSavedMessage(apiError(error),true);}}
async function bulkReview(action){const ids=selectedReviewProspects().map(Number);if(!ids.length)return;const label=action==='verify'?'verify':'reject';if(!window.confirm(`Confirm ${label} of ${ids.length} selected prospect${ids.length===1?'':'s'}? This changes review state only; no outreach will be sent.`))return;const button=$(action==='verify'?'bulkVerifyBtn':'bulkRejectBtn');button.disabled=true;try{await jsonRequest('/api/v1/businesses/bulk-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({business_ids:ids,action,review_action:action})});selectedReviewIds.clear();setExplorerState(`${label[0].toUpperCase()+label.slice(1)} action completed.`);await Promise.all([loadData(),loadReviewQueue()]);}catch(error){if(error.message!=='unauthorized')setExplorerState(apiError(error),true);}finally{updateBulkState();}}
const $ = (id) => document.getElementById(id);
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const scoreClass = (score) => score >= 80 ? 'high' : score >= 60 ? 'medium' : 'low';
@@ -16,12 +35,13 @@
async function jsonRequest(path, options = {}) { const res=await request(path,options); const body=await res.json().catch(()=>({})); if(!res.ok)throw new Error(body.error||body.message||'Request failed'); return body; }
function showLogin(message=''){currentUser=null;$('dashboardShell').hidden=true;$('loginScreen').hidden=false;$('loginMessage').textContent=message;$('loginMessage').className=`form-message${message?' error':''}`;}
function showDashboard(user){currentUser=user||{};const name=currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();}
function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=prospects.filter(p=>scoreFor(p)>=80).length,review=prospects.filter(p=>statusOf(p)==='review').length,fresh=prospects.length?Math.round(prospects.filter(p=>freshness(p).cls==='good').length/prospects.length*100):0;$('metricTotal').textContent=total;$('heroCount').textContent=`${total} prospects`;$('metricReview').textContent=summary?.needs_review??review;$('metricHigh').textContent=summary?.high_fit??high;$('metricFresh').textContent=`${summary?.freshness_under_7d??fresh}%`;}
function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value};}
function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new';return(!q||text.includes(q.toLowerCase()))&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps);});}
function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=Number(summary?.high_fit??summary?.high_fit_count??prospects.filter(p=>scoreFor(p)>=80).length),review=Number(summary?.needs_review??summary?.review_count??prospects.filter(p=>statusOf(p)==='review').length),suppressed=Number(summary?.suppressed??summary?.suppressed_count??prospects.filter(p=>statusOf(p)==='suppressed').length),fresh=prospects.length?Math.round(prospects.filter(p=>freshness(p).cls==='good').length/prospects.length*100):0;$('metricTotal').textContent=total;$('heroCount').textContent=`${total} prospects`;$('metricReview').textContent=review;$('metricHigh').textContent=high;$('metricFresh').textContent=`${summary?.freshness_under_7d??summary?.fresh_count??fresh}%`;if($('metricSuppressed'))$('metricSuppressed').textContent=suppressed;}
function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value,freshness:$('explorerState')?.dataset?.filter||'all'};}
function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps,freshness}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new';return(!q||text.includes(q.toLowerCase()))&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps)&&(freshness!=='under_7d'||freshnessValue(p));});}
const freshnessValue=p=>freshness(p).cls==='good';
function renderRows(){const rows=filtered();$('resultCount').textContent=`Showing ${rows.length} prospect${rows.length===1?'':'s'} · page ${page}`;$('prospectRows').innerHTML=rows.length?rows.map(p=>{const s=scoreFor(p),f=freshness(p),st=statusOf(p),factors=p.score_factors||p.factors||[];return `<tr data-id="${esc(p.id)}" class="${Number(p.id)===Number(selectedId)?'selected':''}"><td>${esc(p.name)}<span class="company-sub">${esc(p.website_domain||'no detected website')}</span></td><td><span class="score ${scoreClass(s)}">${s} <small>/ 100</small></span></td><td class="evidence"><strong>${factors.length} signal${factors.length===1?'':'s'}</strong>${esc(factors.slice(0,2).map(labelFactor).join(' · ')||'Limited evidence')}</td><td><span class="fresh ${f.cls}">${f.label}</span></td><td><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></td><td class="row-arrow"></td></tr>`;}).join(''):'<tr><td colspan="6" class="muted">No prospects match these filters.</td></tr>';$('nextPageBtn').disabled=!hasNextPage;document.querySelectorAll('#prospectRows tr[data-id]').forEach(row=>row.addEventListener('click',()=>selectProspect(row.dataset.id)));}
function listQuery(){const f=filterValues(),params=new URLSearchParams({page:String(page),page_size:String(pageSize)});if(f.q)params.set('q',f.q);if(f.website_class!=='all')params.set('website_class',f.website_class);if(f.pipeline_stage!=='all')params.set('pipeline_stage',f.pipeline_stage);return `?${params}`;}
async function loadData(){ $('apiStatus').textContent='● Connecting…';$('apiStatus').classList.remove('live');try{const [listRes,summaryRes]=await Promise.all([request(`/api/v1/businesses${listQuery()}`),request('/api/v1/dashboard/summary')]);if(!listRes.ok||!summaryRes.ok)throw new Error('API request failed');const list=await listRes.json(),summary=await summaryRes.json();prospects=Array.isArray(list)?list:(list.businesses||list.items||[]);hasNextPage=Boolean(list.has_next??list.next_page??list.next??list.next_cursor);renderMetrics(summary);$('apiStatus').textContent='● API connected';$('apiStatus').classList.add('live');}catch(error){if(error.message==='unauthorized')return;prospects=[];hasNextPage=false;renderMetrics(null);$('apiStatus').textContent='● API unavailable';}renderRows();if(selectedId)loadDetail(selectedId);}
function listQuery(){const f=filterValues(),params=new URLSearchParams({page:String(page),page_size:String(pageSize)});if(f.q)params.set('q',f.q);if(f.score!=='all')params.set('score_band',f.score);if(f.status!=='all')params.set('status',f.status);if(f.website_class!=='all')params.set('website_class',f.website_class);if(f.pipeline_stage!=='all')params.set('pipeline_stage',f.pipeline_stage);if(f.freshness!=='all')params.set('freshness',f.freshness);return `?${params}`;}
async function loadData(){ $('apiStatus').textContent='● Connecting…';$('apiStatus').classList.remove('live');setExplorerState('Loading prospects…');try{const [listRes,summaryRes]=await Promise.all([request(`/api/v1/businesses${listQuery()}`),request('/api/v1/dashboard/summary')]);if(!listRes.ok||!summaryRes.ok)throw new Error('API request failed');const list=await listRes.json(),summary=await summaryRes.json();prospects=Array.isArray(list)?list:(list.businesses||list.items||[]);hasNextPage=Boolean(list.has_next??list.next_page??list.next??list.next_cursor);renderMetrics(summary);$('apiStatus').textContent='● API connected';$('apiStatus').classList.add('live');setExplorerState('');}catch(error){if(error.message==='unauthorized')return;prospects=[];hasNextPage=false;renderMetrics(null);$('apiStatus').textContent='● API unavailable';setExplorerState(`Unable to load prospects: ${apiError(error)}`,true);}renderRows();if(selectedId)loadDetail(selectedId);}
async function selectProspect(id){selectedId=Number(id);selectedDetail=null;renderRows();$('detailPanel').innerHTML='<div class="detail-loading" aria-live="polite">Loading prospect detail…</div>';await loadDetail(selectedId);}
async function loadDetail(id){try{const detail=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}`);selectedDetail=detail;const index=prospects.findIndex(p=>Number(p.id)===Number(id));if(index>=0)prospects[index]={...prospects[index],...detail};renderDetail(detail);}catch(error){if(error.message!=='unauthorized')$('detailPanel').innerHTML=`<div class="detail-error" role="alert"><h3>Unable to load detail</h3><p>${esc(error.message)}</p><button class="button ghost" id="retryDetailBtn" type="button">Try again</button></div>`;}}
const listItems=(items,empty,label)=>Array.isArray(items)&&items.length?`<ul class="detail-list">${items.map(item=>`<li>${esc(typeof item==='string'?item:item[label]||item.value||item.name||JSON.stringify(item))}</li>`).join('')}</ul>`:`<p class="muted">${empty}</p>`;
@@ -187,9 +207,10 @@
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();}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();}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'});}));
bootstrap();
})();
+8 -5
View File
@@ -41,18 +41,21 @@
<div class="content">
<section class="hero" id="dashboard"><div><p class="eyebrow">EVIDENCE-LED PROSPECTING</p><h1>Good morning, Alex <span></span></h1><p class="hero-sub">Your pipeline has <strong id="heroCount">0 prospects</strong> ready for review.</p></div><button class="button primary" data-scroll="#add"> Add prospect</button></section>
<section class="metrics" aria-label="Dashboard metrics">
<article class="metric-card"><div class="metric-icon violet"></div><div><p>Total prospects</p><h2 id="metricTotal">0</h2><span class="trend neutral">● Current workspace</span></div></article>
<article class="metric-card"><div class="metric-icon amber"></div><div><p>Needs review</p><h2 id="metricReview">0</h2><span class="trend neutral">● Human verification</span></div></article>
<article class="metric-card"><div class="metric-icon green"></div><div><p>High-fit prospects</p><h2 id="metricHigh">0</h2><span class="trend neutral">● Score 80+</span></div></article>
<article class="metric-card"><div class="metric-icon blue"></div><div><p>Freshness under 7d</p><h2 id="metricFresh">0%</h2><span class="trend neutral">● Evidence coverage</span></div></article>
<a class="metric-card metric-link" data-dashboard-filter="all" href="#explorer"><div class="metric-icon violet"></div><div><p>Total prospects</p><h2 id="metricTotal">0</h2><span class="trend neutral">● Current workspace</span></div></a>
<a class="metric-card metric-link" data-dashboard-filter="review" href="#explorer"><div class="metric-icon amber"></div><div><p>Needs review</p><h2 id="metricReview">0</h2><span class="trend neutral">● Human verification</span></div></a>
<a class="metric-card metric-link" data-dashboard-filter="high" href="#explorer"><div class="metric-icon green"></div><div><p>High-fit prospects</p><h2 id="metricHigh">0</h2><span class="trend neutral">● Score 80+</span></div></a>
<a class="metric-card metric-link" data-dashboard-filter="fresh" href="#explorer"><div class="metric-icon blue"></div><div><p>Freshness under 7d</p><h2 id="metricFresh">0%</h2><span class="trend neutral">● Evidence coverage</span></div></a>
<a class="metric-card metric-link" data-dashboard-filter="suppressed" href="#explorer"><div class="metric-icon violet"></div><div><p>Suppressed</p><h2 id="metricSuppressed">0</h2><span class="trend neutral">● Do not contact</span></div></a>
</section>
<section class="score-overview" aria-label="Score overview"><article class="panel score-distribution-panel" id="scoreDistributionPanel" data-smoke="score-distribution"><div class="detail-loading" aria-live="polite">Sign in to load score distribution…</div></article></section>
<section class="score-rules-section" id="scoreRules" aria-labelledby="scoreRulesTitle"><article class="panel" id="scoreRulesPanel" data-smoke="score-rules"><div class="detail-loading" aria-live="polite">Sign in to load score rules…</div></article></section>
<section class="workspace-grid" id="explorer">
<div class="explorer-panel panel"><div class="panel-heading"><div><p class="eyebrow">PIPELINE</p><h2>Prospect explorer</h2></div><button class="button ghost" id="refreshBtn">↻ Refresh</button></div>
<div class="filters"><label class="search-wrap"><span></span><input id="searchInput" type="search" placeholder="Search companies, domains, locations…" autocomplete="off"></label><select id="scoreFilter" aria-label="Filter by score"><option value="all">All scores</option><option value="high">High fit · 80+</option><option value="medium">Medium · 6079</option><option value="low">Low · under 60</option></select><select id="statusFilter" aria-label="Filter by status"><option value="all">All statuses</option><option value="review">Needs review</option><option value="reviewed">Reviewed</option><option value="suppressed">Suppressed</option></select><select id="websiteClassFilter" aria-label="Filter by website class"><option value="all">All website classes</option><option value="business_site">Business site</option><option value="social_profile">Social profile</option><option value="missing">Missing website</option></select><select id="pipelineFilter" aria-label="Filter by pipeline stage"><option value="all">All pipeline stages</option><option value="new">New</option><option value="qualified">Qualified</option><option value="review">Review</option><option value="suppressed">Suppressed</option></select></div>
<div class="saved-view-controls" data-smoke="saved-views"><form id="savedFilterForm" class="inline-form"><input id="savedFilterName" name="name" placeholder="Save current filters as…" maxlength="80" required><button class="button ghost compact" type="submit">Save view</button></form><select id="savedFilterSelect" aria-label="Load saved view"><option value="">Saved views</option></select><button class="button ghost compact" id="deleteSavedFilterBtn" type="button" disabled>Delete view</button><p id="savedFilterMessage" class="form-message" role="status" aria-live="polite"></p></div>
<section class="review-queue" data-smoke="review-queue"><div class="queue-heading"><div><p class="eyebrow">OPERATOR QUEUE</p><h3>Review queue <span class="count" id="reviewQueueCount">0</span></h3></div><span class="small-label">Human decision required</span></div><div id="reviewQueueState" class="queue-state" aria-live="polite">Loading review queue…</div><div class="bulk-actions"><label class="checkbox-label"><input id="selectAllReview" type="checkbox"> Select visible</label><span id="selectedReviewCount" class="small-label">0 selected</span><button class="button primary compact" id="bulkVerifyBtn" type="button" disabled>Verify selected</button><button class="button danger compact" id="bulkRejectBtn" type="button" disabled>Reject selected</button></div></section>
<div class="table-meta"><span id="resultCount">Showing 0 prospects</span><span class="legend"><span class="legend-dot high-dot"></span> High fit <span class="legend-dot review-dot"></span> Needs review</span><label class="page-size">Rows <select id="pageSize" aria-label="Page size"><option>10</option><option>25</option><option>50</option></select></label><button class="button ghost compact" id="nextPageBtn" type="button">Next page →</button></div>
<div class="table-scroll"><table><thead><tr><th>Company</th><th>Fit score</th><th>Evidence</th><th>Freshness</th><th>Status</th><th></th></tr></thead><tbody id="prospectRows"></tbody></table></div>
<div id="explorerState" class="explorer-state" aria-live="polite"></div><div class="table-scroll"><table><thead><tr><th>Company</th><th>Fit score</th><th>Evidence</th><th>Freshness</th><th>Status</th><th></th></tr></thead><tbody id="prospectRows"></tbody></table></div>
</div>
<aside class="detail-panel panel" id="detailPanel"><div class="empty-detail"><span class="empty-icon"></span><h3>Select a prospect</h3><p>Review evidence, confidence, and eligibility before taking action.</p></div></aside>
</section>
+4
View File
@@ -49,5 +49,9 @@ frame.onload=async()=>{const d=frame.contentDocument;const js=await fetch('app.j
,['Score rules configuration and permission affordance',()=>!!d.querySelector('[data-smoke="score-rules"]')&&!!d.querySelector('#scoreRules')&&js.includes('/api/v1/score-rules')&&js.includes('enabled')&&js.includes('points')&&js.includes('Read-only for this role')&&js.includes('canEditScores')]
,['Score distribution summary and safe states',()=>!!d.querySelector('[data-smoke="score-distribution"]')&&js.includes('/scoring/summary')&&js.includes('Loading score distribution')&&js.includes('Unable to load score distribution')&&js.includes('tenant-scoped')&&js.includes('do not override suppression')]
,['Score responsive styles',()=>js.includes('score-breakdown-summary')&&js.includes('score-config-row')&&js.includes('distribution-track')&&js.includes('@media')]
,['Phase 11 saved views and review queue controls',()=>!!d.querySelector('[data-smoke="saved-views"]')&&!!d.querySelector('[data-smoke="review-queue"]')&&!!d.querySelector('#savedFilterForm')&&!!d.querySelector('#savedFilterSelect')&&!!d.querySelector('#bulkVerifyBtn')&&!!d.querySelector('#bulkRejectBtn')]
,['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')]
];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>
+3
View File
@@ -5,4 +5,7 @@
.jobs-section{margin-top:28px;scroll-margin-top:24px}.jobs-header{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.jobs-header h2{margin:.15rem 0 .25rem}.jobs-header p{margin:.25rem 0 0}.jobs-actions{display:flex;gap:8px;flex-wrap:wrap}.jobs-message{min-height:22px;padding:8px 2px;color:var(--green)}.jobs-message.error{color:var(--red)}.job-counts{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:0 0 14px}.job-count{background:var(--surface);border:1px solid var(--line);border-left:4px solid var(--violet);border-radius:10px;padding:14px 16px;box-shadow:var(--shadow)}.job-count span{display:block;color:var(--muted);font-size:12px}.job-count strong{display:block;font-size:25px;margin-top:4px}.job-count.queued{border-left-color:var(--amber)}.job-count.running{border-left-color:#4d8bd8}.job-count.succeeded{border-left-color:var(--green)}.job-count.failed{border-left-color:var(--red)}.job-count.cancelled{border-left-color:#8d879c}.jobs-grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(320px,.95fr);gap:18px}.jobs-list,.job-detail{min-height:320px}.jobs-list-body{border-top:1px solid var(--line)}.job-empty{padding:38px 18px;color:var(--muted);text-align:center}.job-row{display:grid;grid-template-columns:minmax(0,1fr) auto 42px;gap:12px;align-items:center;width:100%;padding:14px 16px;border:0;border-bottom:1px solid var(--line);background:transparent;color:inherit;text-align:left;cursor:pointer;font:inherit}.job-row:hover,.job-row.selected{background:var(--violet-soft)}.job-row-main{display:flex;flex-direction:column;gap:3px;min-width:0}.job-row-main strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.job-row-main small,.job-row-progress{color:var(--muted);font-size:12px}.job-row-state{border-radius:999px;padding:4px 8px;font-size:11px;font-weight:700;white-space:nowrap;background:var(--violet-soft);color:var(--violet)}.job-row-state.queued{background:var(--amber-soft);color:var(--amber)}.job-row-state.running{background:#e8f1ff;color:#3269ad}.job-row-state.succeeded{background:var(--green-soft);color:var(--green)}.job-row-state.failed{background:var(--red-soft);color:var(--red)}.job-row-state.cancelled{background:#f0eef4;color:#716a7c}.job-detail{padding:22px}.job-detail-head{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line);padding-bottom:16px}.job-detail-head h3{margin:.15rem 0}.job-progress{padding:18px 0}.job-progress-meta{display:flex;justify-content:space-between;gap:12px;font-size:12px;color:var(--muted)}.job-progress-meta strong{color:var(--ink)}.progress-track{height:8px;background:#eef0f5;border-radius:999px;overflow:hidden;margin-top:10px}.progress-track span{display:block;height:100%;background:var(--violet);border-radius:inherit;transition:width .25s}.structured-error{background:var(--red-soft);border:1px solid #f2cdd0;border-radius:8px;padding:12px;margin:4px 0 16px;color:var(--red)}.structured-error p{margin:5px 0}.structured-error pre{white-space:pre-wrap;font-size:11px;margin:8px 0 0}.job-detail-actions{display:flex;gap:8px;min-height:34px}.event-timeline{border-top:1px solid var(--line);margin-top:16px;padding-top:16px}.event-timeline h4{margin:0}.event-timeline ol{list-style:none;padding:0;margin:12px 0 0}.event-timeline li{display:flex;gap:10px;position:relative;padding:0 0 15px}.event-timeline li:not(:last-child):before{content:"";position:absolute;left:4px;top:10px;bottom:0;border-left:1px solid var(--line)}.timeline-dot{z-index:1;width:9px;height:9px;margin-top:4px;border-radius:50%;background:var(--violet);flex:none}.event-timeline li div{display:flex;flex-direction:column;gap:2px}.event-timeline small,.timeline-progress{font-size:11px;color:var(--muted)}@media(max-width:900px){.jobs-grid{grid-template-columns:1fr}.job-counts{grid-template-columns:repeat(3,1fr)}}@media(max-width:700px){.jobs-header{flex-direction:column}.job-counts{grid-template-columns:repeat(2,1fr)}.job-row{grid-template-columns:minmax(0,1fr) auto}.job-row-progress{display:none}}
.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)}
@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)}}
+10
View File
@@ -99,6 +99,16 @@ Creation must use a tenant-scoped idempotency key and request fingerprint. A rep
There is no SSE handler, durable queue, scan worker/isolation boundary, or worker process in Compose; its in-process worker and SQLite job/event tables are pilot-only. Process loss can lose work, there is no durable lease/recovery or horizontal coordination, and it must not be presented as production execution. Redis and Celery are not implemented.
## Phase 11 dashboard and review workflow operations
Operate saved filters as tenant-scoped configuration. Before exposing save/load/update/delete, verify the filter schema, server-side bounds, owner/share permissions, stable ordering, page-size maximum, retention/deletion behavior, and audit event format. A filter is not a permission grant; never troubleshoot a missing record by removing tenant or eligibility predicates.
The review queue must display the exact filter/predicate snapshot, whether each count is for the current page or the full matching set, and pagination/cursor state. Re-check tenant scope and current suppression/merge state when a row is opened or acted on. Suppressed/do-not-contact records remain blocked; merged/non-active records are not merge-eligible. A clickable count is safe only when it reuses the same bounded, tenant-scoped predicate; do not interpret stale, loading, or failed counts as zero.
For each bulk operation, verify the server-reported selection size and maximum, inspect the preview, obtain explicit operator confirmation, and ensure an idempotency key/correlation ID is recorded. The server must re-check permissions, tenant ownership, suppression, and merge eligibility at execution. Read back the per-record outcomes and audit event; investigate skipped/failed records and partial completion rather than retrying blindly. Never run an unbounded “all results” action from a count, and never describe bulk review as outreach or automatic deduplication.
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.
## 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.
+10
View File
@@ -87,6 +87,16 @@ If a future approved adapter fetches URLs, apply the SSRF requirements below in
Phase 10 is not production-ready until rule-set lifecycle permissions/approval, immutable snapshots, audit tamper resistance, retention/deletion policy, concurrency/rollback behavior, and regression tests for suppression precedence, stale/uncertain handling, replay/reproducibility, and tenant isolation are complete.
## Phase 11 dashboard and review workflow security controls
- Saved filters are tenant-owned data, not authorization. Validate and bound every predicate server-side; scope list/read/write/delete by the authenticated organization, reject unknown fields and unbounded limits, and keep filter definitions free of secrets. Any shared filter must remain within the tenant with explicit read/write permissions.
- Review-queue membership and counts must be derived under the caller's tenant and permission predicate. A count, filter ID, cursor, or selected business ID must never grant access. Suppression/do-not-contact always overrides score, review status, or bulk selection; merged/non-active records are not merge-eligible.
- Bulk actions require a server-enforced batch maximum, explicit selection or immutable filter snapshot, preview plus confirmation, authorization and state re-checks at execution, and idempotent/safely retryable behavior. Return bounded per-record outcomes and fail closed on cross-tenant, suppressed, stale, or ineligible records. Bulk review actions must not become an outreach or automatic-merge path.
- Clickable counts must preserve the exact tenant-scoped predicate used to calculate them and clearly distinguish page counts from full matching-set counts. Never use a client-side count as a security decision; loading/error/unavailable must not collapse to zero and leak scope or state.
- Audit every saved-filter mutation, queue assignment/decision, bulk preview/confirmation/execution, skip/failure, suppression/eligibility decision, and merge/reversal. Record tenant, actor, action, timestamp, filter/selection snapshot or safe hash, bounded counts, per-item outcomes, policy/version, and correlation/idempotency ID. Protect audit reads with the same tenant predicate, redact secrets/full contact values where unnecessary, and define retention/deletion and tamper-evidence before production.
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.
## 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.