GitHub Actions commited on
Commit
89a70e2
·
1 Parent(s): b7c46f4

Sync from GitHub · 2026-10-22 20:43 UTC

Browse files
routes/admin.py CHANGED
@@ -10,6 +10,8 @@ Routes:
10
  DELETE /api/admin/users/{uid} → delete user account + Firestore data
11
  GET /api/admin/stats → platform overview stats
12
  GET /api/admin/audit → audit trail of manual admin actions
 
 
13
  """
14
 
15
  from fastapi import APIRouter, HTTPException, Depends
@@ -18,6 +20,8 @@ from firebase_admin import auth as fb_auth, firestore
18
  from auth import verify_firebase_token
19
  from routes.account import delete_all_user_data
20
  from utils.supabase_client import get_supabase
 
 
21
 
22
  router = APIRouter()
23
 
@@ -140,3 +144,26 @@ def platform_stats(user: dict = Depends(verify_firebase_token)):
140
  "totalUsers": user_count,
141
  "note": "Deep counts require Cloud Functions — this shows top-level totals only."
142
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  DELETE /api/admin/users/{uid} → delete user account + Firestore data
11
  GET /api/admin/stats → platform overview stats
12
  GET /api/admin/audit → audit trail of manual admin actions
13
+ GET /api/admin/niche-requests → niches people couldn't find, most requested first
14
+ PUT /api/admin/niche-requests/{id} → mark one added / dismissed / open
15
  """
16
 
17
  from fastapi import APIRouter, HTTPException, Depends
 
20
  from auth import verify_firebase_token
21
  from routes.account import delete_all_user_data
22
  from utils.supabase_client import get_supabase
23
+ from utils import niche_requests
24
+ from utils.audit import log_action
25
 
26
  router = APIRouter()
27
 
 
144
  "totalUsers": user_count,
145
  "note": "Deep counts require Cloud Functions — this shows top-level totals only."
146
  }
147
+
148
+
149
+ class NicheStatusRequest(BaseModel):
150
+ status: str # open | added | dismissed
151
+
152
+
153
+ @router.get("/niche-requests")
154
+ def list_niche_requests(status: str | None = None, user: dict = Depends(verify_firebase_token)):
155
+ _verify_admin(user)
156
+ return {"requests": niche_requests.list_requests(status)}
157
+
158
+
159
+ @router.put("/niche-requests/{key}")
160
+ def update_niche_request(key: str, body: NicheStatusRequest, user: dict = Depends(verify_firebase_token)):
161
+ admin_uid = _verify_admin(user)
162
+ try:
163
+ text = niche_requests.set_status(key, body.status)
164
+ except ValueError as e:
165
+ raise HTTPException(status_code=400, detail=str(e))
166
+ except KeyError:
167
+ raise HTTPException(status_code=404, detail="Niche request not found.")
168
+ log_action(admin_uid, "niche", body.status, text, target=key)
169
+ return {"success": True}
routes/bff.py CHANGED
@@ -1,11 +1,12 @@
1
  import json
2
  from fastapi import APIRouter, HTTPException, Depends
3
- from pydantic import BaseModel
4
  from firebase_admin import firestore
5
  from auth import verify_firebase_token
6
  from utils.rate_limiter import with_rate_limit
7
  from utils.groq_client import call_groq_json, HEAVY_MODEL
8
  from utils.pinecone_client import query_book_context
 
9
 
10
  router = APIRouter()
11
 
@@ -28,6 +29,12 @@ class ReopenFollowupsRequest(BaseModel):
28
  businessId: str
29
 
30
 
 
 
 
 
 
 
31
  # ─── Helpers ──────────────────────────────────────────────────────────────────
32
 
33
  def _db():
@@ -216,7 +223,9 @@ def generate_profile(
216
 
217
  # ── Retrieve relevant passages from "Built on Rock" via Pinecone ─────────
218
  sector = bff.get("section1", {}).get("sectorId", "")
219
- niche = bff.get("section1", {}).get("nicheId", "")
 
 
220
  book_ctx = query_book_context(
221
  f"Business Footing Framework WHAT WHY HOW {sector} {niche} Nigerian entrepreneur",
222
  top_k=3,
@@ -464,3 +473,15 @@ def reopen_followups(
464
  if missing:
465
  biz_ref.update({"bffCompleted": False, "profileStale": True})
466
  return {"missing": missing}
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
  from fastapi import APIRouter, HTTPException, Depends
3
+ from pydantic import BaseModel, Field
4
  from firebase_admin import firestore
5
  from auth import verify_firebase_token
6
  from utils.rate_limiter import with_rate_limit
7
  from utils.groq_client import call_groq_json, HEAVY_MODEL
8
  from utils.pinecone_client import query_book_context
9
+ from utils import niche_requests
10
 
11
  router = APIRouter()
12
 
 
29
  businessId: str
30
 
31
 
32
+ class NicheRequestIn(BaseModel):
33
+ text: str = Field(min_length=2, max_length=80)
34
+ sectorId: str = Field(default="", max_length=60)
35
+ subsectorId: str = Field(default="", max_length=60)
36
+
37
+
38
  # ─── Helpers ──────────────────────────────────────────────────────────────────
39
 
40
  def _db():
 
223
 
224
  # ── Retrieve relevant passages from "Built on Rock" via Pinecone ─────────
225
  sector = bff.get("section1", {}).get("sectorId", "")
226
+ s1 = bff.get("section1", {})
227
+ # nicheId "other" means the person described their own niche in customNiche.
228
+ niche = s1.get("customNiche") if s1.get("nicheId") == "other" else s1.get("nicheId", "")
229
  book_ctx = query_book_context(
230
  f"Business Footing Framework WHAT WHY HOW {sector} {niche} Nigerian entrepreneur",
231
  top_k=3,
 
473
  if missing:
474
  biz_ref.update({"bffCompleted": False, "profileStale": True})
475
  return {"missing": missing}
476
+
477
+
478
+ # ─── Route: Niche not in our list ────────────────────────────────────────────
479
+
480
+ @router.post("/niche-request")
481
+ def niche_request(body: NicheRequestIn, user: dict = Depends(verify_firebase_token)):
482
+ """Records a niche the person couldn't find, for admins to review and add."""
483
+ try:
484
+ niche_requests.record(user["uid"], body.text, body.sectorId, body.subsectorId)
485
+ except ValueError as e:
486
+ raise HTTPException(status_code=400, detail=str(e))
487
+ return {"success": True}
routes/chat.py CHANGED
@@ -6,6 +6,7 @@ from firebase_admin import firestore
6
  from utils.rate_limiter import with_rate_limit
7
  from utils.groq_client import call_groq_stream, LIGHT_MODEL, HEAVY_MODEL
8
  from utils.pinecone_client import query_book_context
 
9
 
10
  router = APIRouter()
11
 
@@ -63,10 +64,12 @@ def _get_business_context(user_id: str, business_id: str) -> str:
63
 
64
  if s1.exists:
65
  d = s1.to_dict()
 
66
  ctx_parts.append(
67
  f"Business: {d.get('businessName', 'Unknown')}, "
68
- f"Sector: {d.get('sector', '')}, "
69
- f"Niche: {d.get('niche', '')}"
 
70
  )
71
 
72
  if identity.exists:
 
6
  from utils.rate_limiter import with_rate_limit
7
  from utils.groq_client import call_groq_stream, LIGHT_MODEL, HEAVY_MODEL
8
  from utils.pinecone_client import query_book_context
9
+ from utils import business_type
10
 
11
  router = APIRouter()
12
 
 
64
 
65
  if s1.exists:
66
  d = s1.to_dict()
67
+ t = business_type.describe(d)
68
  ctx_parts.append(
69
  f"Business: {d.get('businessName', 'Unknown')}, "
70
+ f"Sector: {t['sector']}, "
71
+ f"Type of business: {t['subsector']}, "
72
+ f"Niche: {t['niche']}"
73
  )
74
 
75
  if identity.exists:
routes/documents.py CHANGED
@@ -20,6 +20,7 @@ from firebase_admin import firestore
20
  from utils.rate_limiter import with_rate_limit
21
  from utils.groq_client import call_groq_json, HEAVY_MODEL
22
  from utils.pinecone_client import query_book_context
 
23
 
24
  router = APIRouter()
25
 
@@ -159,11 +160,8 @@ def generate_document_content(
159
  raise HTTPException(status_code=404, detail="Business profile not found.")
160
 
161
  # ── BFF Philosophy context from Pinecone ──────────────────────────────────
162
- query = (
163
- f"business plan entrepreneurship Nigeria "
164
- f"{ctx.get('businessIdentity', {}).get('sector', '')} "
165
- f"{ctx.get('section1', {}).get('niche', '')}"
166
- )
167
  book_context = query_book_context(query, top_k=3)
168
 
169
  # ── Generate ──────────────────────────────────────────────────────────────
 
20
  from utils.rate_limiter import with_rate_limit
21
  from utils.groq_client import call_groq_json, HEAVY_MODEL
22
  from utils.pinecone_client import query_book_context
23
+ from utils import business_type
24
 
25
  router = APIRouter()
26
 
 
160
  raise HTTPException(status_code=404, detail="Business profile not found.")
161
 
162
  # ── BFF Philosophy context from Pinecone ──────────────────────────────────
163
+ t = business_type.describe(ctx.get("section1", {}))
164
+ query = f"business plan entrepreneurship Nigeria {t['subsector']} {t['niche']}"
 
 
 
165
  book_context = query_book_context(query, top_k=3)
166
 
167
  # ── Generate ──────────────────────────────────────────────────────────────
sql/admin_audit_schema.sql CHANGED
@@ -10,10 +10,16 @@ create table if not exists admin_audit_log (
10
  id uuid primary key default gen_random_uuid(),
11
  at timestamptz not null default now(),
12
  actor text not null, -- admin's Firebase UID
13
- kind text not null check (kind in ('review', 'source', 'run')),
14
  action text not null, -- e.g. 'published', 'discarded', 'enabled', 'disabled', 'started'
15
  target text, -- the item/source/poll id this action was on
16
  summary text not null -- short human-readable description, e.g. the item title
17
  );
18
 
19
  create index if not exists admin_audit_log_at_idx on admin_audit_log (at desc);
 
 
 
 
 
 
 
10
  id uuid primary key default gen_random_uuid(),
11
  at timestamptz not null default now(),
12
  actor text not null, -- admin's Firebase UID
13
+ kind text not null check (kind in ('review', 'source', 'run', 'niche')),
14
  action text not null, -- e.g. 'published', 'discarded', 'enabled', 'disabled', 'started'
15
  target text, -- the item/source/poll id this action was on
16
  summary text not null -- short human-readable description, e.g. the item title
17
  );
18
 
19
  create index if not exists admin_audit_log_at_idx on admin_audit_log (at desc);
20
+
21
+ -- 2026-10-22: niche requests (marking a missing niche added/dismissed) are
22
+ -- audited too. For a table created before this date, run:
23
+ alter table admin_audit_log drop constraint if exists admin_audit_log_kind_check;
24
+ alter table admin_audit_log add constraint admin_audit_log_kind_check
25
+ check (kind in ('review', 'source', 'run', 'niche'));
tests/test_business_type.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from utils.business_type import describe
2
+
3
+
4
+ def test_ids_become_readable_labels():
5
+ t = describe({"sectorId": "food_agriculture", "subsectorId": "catering_food_service", "nicheId": "outdoor_catering"})
6
+ assert t == {"sector": "Food agriculture", "subsector": "Catering & Food Service", "niche": "Outdoor catering"}
7
+
8
+
9
+ def test_other_niche_uses_the_persons_own_words():
10
+ t = describe({"sectorId": "trade_commerce", "subsectorId": "retail_trade", "nicheId": "other", "customNiche": " drone repair "})
11
+ assert t["niche"] == "drone repair"
12
+
13
+
14
+ def test_missing_fields_are_empty():
15
+ assert describe({}) == {"sector": "", "subsector": "", "niche": ""}
tests/test_niche_requests.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for utils/niche_requests.py and the /niche-request route: the list of
3
+ niches people couldn't find. Firestore is mocked.
4
+ """
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+ from fastapi import HTTPException
9
+
10
+ from utils import niche_requests
11
+ from routes.bff import NicheRequestIn, niche_request
12
+
13
+
14
+ def test_key_ignores_case_spacing_and_punctuation():
15
+ assert niche_requests.niche_key(" School Stationery! ") == "school-stationery"
16
+ assert niche_requests.niche_key("school stationery") == "school-stationery"
17
+ assert niche_requests.niche_key("!!!") == ""
18
+
19
+
20
+ def test_route_rejects_text_without_letters():
21
+ with pytest.raises(HTTPException) as e:
22
+ niche_request(NicheRequestIn(text="!!!"), user={"uid": "u1"})
23
+ assert e.value.status_code == 400
24
+
25
+
26
+ def test_list_hides_who_asked_and_sorts_by_count():
27
+ def doc(id_, **d):
28
+ m = MagicMock(id=id_)
29
+ m.to_dict.return_value = d
30
+ return m
31
+ db = MagicMock()
32
+ db.collection.return_value.stream.return_value = [
33
+ doc("a", text="A", userCount=1, uids=["u1"], status="open"),
34
+ doc("b", text="B", userCount=3, uids=["u1", "u2", "u3"], status="open"),
35
+ doc("c", text="C", userCount=9, uids=["x"], status="added"),
36
+ ]
37
+ with patch.object(niche_requests.firestore, "client", return_value=db):
38
+ rows = niche_requests.list_requests("open")
39
+ assert [r["text"] for r in rows] == ["B", "A"]
40
+ assert all("uids" not in r for r in rows)
41
+
42
+
43
+ def test_set_status_validates():
44
+ with pytest.raises(ValueError):
45
+ niche_requests.set_status("x", "deleted")
46
+
47
+
48
+ def _fake_firestore(existing: dict | None):
49
+ fs = MagicMock()
50
+ fs.transactional = lambda f: f # run the body directly with our fake transaction
51
+ snap = MagicMock(exists=existing is not None)
52
+ snap.to_dict.return_value = existing or {}
53
+ ref = fs.client.return_value.collection.return_value.document.return_value
54
+ ref.get.return_value = snap
55
+ tx = fs.client.return_value.transaction.return_value
56
+ return fs, ref, tx
57
+
58
+
59
+ def test_record_counts_each_person_once_and_keeps_where_they_put_it():
60
+ fs, ref, tx = _fake_firestore({"uids": ["u1"], "sectors": ["trade_commerce/retail_trade"], "status": "added"})
61
+ with patch.object(niche_requests, "firestore", fs):
62
+ key = niche_requests.record("u1", "School stationery", "trade_commerce", "retail_trade")
63
+ assert key == "school-stationery"
64
+ saved = tx.set.call_args[0][1]
65
+ assert saved["userCount"] == 1 and saved["uids"] == ["u1"]
66
+ assert saved["sectors"] == ["trade_commerce/retail_trade"]
67
+ assert saved["status"] == "added" # an admin decision isn't reset by a new request
68
+ assert saved["text"] == "School stationery"
69
+ assert "firstAt" not in saved
70
+
71
+
72
+ def test_record_new_niche_starts_open_with_one_person():
73
+ fs, ref, tx = _fake_firestore(None)
74
+ with patch.object(niche_requests, "firestore", fs):
75
+ niche_requests.record("u2", "Shoe repair")
76
+ saved = tx.set.call_args[0][1]
77
+ assert saved["userCount"] == 1 and saved["status"] == "open" and "firstAt" in saved
78
+ assert saved["sectors"] == []
utils/business_type.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Readable sector / type of business / niche from a BFF section1 doc.
3
+
4
+ section1 stores only ids (sectorId, subsectorId, nicheId), plus customNiche when
5
+ nicheId is "other". Sub-sector labels come from the exported subsectors.json;
6
+ sector and niche ids are snake_case, so they read fine once humanised.
7
+ """
8
+
9
+ from news.business_types import SUBSECTORS
10
+
11
+ OTHER_NICHE_ID = "other"
12
+ _SUBSECTOR_LABELS = {s["id"]: s["label"] for s in SUBSECTORS}
13
+
14
+
15
+ def _humanise(id_: str) -> str:
16
+ return id_.replace("_", " ").strip().capitalize()
17
+
18
+
19
+ def describe(s1: dict) -> dict:
20
+ """{'sector': ..., 'subsector': ..., 'niche': ...}, '' for anything not set."""
21
+ sub_id = s1.get("subsectorId", "")
22
+ niche_id = s1.get("nicheId", "")
23
+ if niche_id == OTHER_NICHE_ID:
24
+ niche = (s1.get("customNiche") or "").strip()
25
+ else:
26
+ niche = _humanise(niche_id)
27
+ return {
28
+ "sector": _humanise(s1.get("sectorId", "")),
29
+ "subsector": _SUBSECTOR_LABELS.get(sub_id, _humanise(sub_id)),
30
+ "niche": niche,
31
+ }
utils/niche_requests.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Niches people couldn't find in data/sectors.ts and described in their own words.
3
+
4
+ One Firestore doc per distinct niche (keyed by its normalised text), so repeat
5
+ requests add up instead of piling up as duplicates. Admins read the list in the
6
+ console to decide which niches to add. firestore.rules' default deny keeps the
7
+ collection backend-only.
8
+ """
9
+
10
+ import re
11
+
12
+ from firebase_admin import firestore
13
+
14
+ COLLECTION = "niche_requests"
15
+ STATUSES = {"open", "added", "dismissed"}
16
+ MAX_USERS_TRACKED = 1000 # beyond this the count stops rising; plenty to rank by
17
+
18
+
19
+ def niche_key(text: str) -> str:
20
+ """'School Stationery!' and 'school stationery' are the same request."""
21
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:100]
22
+
23
+
24
+ def record(uid: str, text: str, sector_id: str = "", subsector_id: str = "") -> str:
25
+ key = niche_key(text)
26
+ if not key:
27
+ raise ValueError("Niche text has no letters or numbers.")
28
+ db = firestore.client()
29
+ ref = db.collection(COLLECTION).document(key)
30
+
31
+ @firestore.transactional
32
+ def _upsert(tx):
33
+ snap = ref.get(transaction=tx)
34
+ d = snap.to_dict() if snap.exists else {}
35
+ uids = list(d.get("uids", []))
36
+ if uid not in uids and len(uids) < MAX_USERS_TRACKED:
37
+ uids.append(uid)
38
+ sectors = list(d.get("sectors", []))
39
+ where = "/".join(p for p in (sector_id, subsector_id) if p)
40
+ if where and where not in sectors:
41
+ sectors.append(where)
42
+ update = {
43
+ "text": " ".join(text.split()),
44
+ "uids": uids,
45
+ "userCount": len(uids),
46
+ "sectors": sectors[-20:],
47
+ "status": d.get("status", "open"),
48
+ "lastAt": firestore.SERVER_TIMESTAMP,
49
+ }
50
+ if not snap.exists:
51
+ update["firstAt"] = firestore.SERVER_TIMESTAMP
52
+ tx.set(ref, update, merge=True)
53
+
54
+ _upsert(db.transaction())
55
+ return key
56
+
57
+
58
+ def list_requests(status: str | None = None) -> list[dict]:
59
+ """Most-requested first. Never returns who asked, only how many."""
60
+ out = []
61
+ for doc in firestore.client().collection(COLLECTION).stream():
62
+ d = doc.to_dict()
63
+ if status and d.get("status", "open") != status:
64
+ continue
65
+ out.append({
66
+ "id": doc.id,
67
+ "text": d.get("text", ""),
68
+ "userCount": d.get("userCount", 0),
69
+ "sectors": d.get("sectors", []),
70
+ "status": d.get("status", "open"),
71
+ "firstAt": str(d.get("firstAt", "")),
72
+ "lastAt": str(d.get("lastAt", "")),
73
+ })
74
+ return sorted(out, key=lambda r: (-r["userCount"], r["text"]))
75
+
76
+
77
+ def set_status(key: str, status: str) -> str:
78
+ """Returns the niche text, for the audit trail."""
79
+ if status not in STATUSES:
80
+ raise ValueError(f"status must be one of {sorted(STATUSES)}")
81
+ ref = firestore.client().collection(COLLECTION).document(key)
82
+ snap = ref.get()
83
+ if not snap.exists:
84
+ raise KeyError(key)
85
+ ref.update({"status": status})
86
+ return snap.to_dict().get("text", key)