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

Sync from GitHub · 2026-10-22 23:15 UTC

Browse files
bsa/__init__.py ADDED
File without changes
bsa/questions.json ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "order": [
3
+ "ST",
4
+ "AN",
5
+ "LD",
6
+ "SA",
7
+ "MK",
8
+ "OP",
9
+ "FN"
10
+ ],
11
+ "stages": [
12
+ "I haven't started anything yet",
13
+ "I have an idea but haven't started",
14
+ "I've started and have early customers or sales (less than 2 years)",
15
+ "I've been in business for 2 to 3 years",
16
+ "I've been in business for 4 to 5 years",
17
+ "I've been in business for 6 to 9 years",
18
+ "I've been in business for 10 years or more"
19
+ ],
20
+ "ages": [
21
+ "Under 18",
22
+ "18 to 24",
23
+ "25 to 34",
24
+ "35 and above"
25
+ ],
26
+ "questions": [
27
+ {
28
+ "id": 1,
29
+ "naturalFit": "ST",
30
+ "dims": [
31
+ "ST",
32
+ "AN",
33
+ "SA",
34
+ "FN"
35
+ ]
36
+ },
37
+ {
38
+ "id": 2,
39
+ "naturalFit": "SA",
40
+ "dims": [
41
+ "ST",
42
+ "SA",
43
+ "MK",
44
+ "OP"
45
+ ]
46
+ },
47
+ {
48
+ "id": 3,
49
+ "naturalFit": "FN",
50
+ "dims": [
51
+ "OP",
52
+ "ST",
53
+ "FN",
54
+ "LD"
55
+ ]
56
+ },
57
+ {
58
+ "id": 4,
59
+ "naturalFit": "LD",
60
+ "dims": [
61
+ "OP",
62
+ "ST",
63
+ "MK",
64
+ "LD"
65
+ ]
66
+ },
67
+ {
68
+ "id": 5,
69
+ "naturalFit": "OP",
70
+ "dims": [
71
+ "OP",
72
+ "LD",
73
+ "FN",
74
+ "ST"
75
+ ]
76
+ },
77
+ {
78
+ "id": 6,
79
+ "naturalFit": "AN",
80
+ "dims": [
81
+ "OP",
82
+ "AN",
83
+ "FN",
84
+ "LD"
85
+ ]
86
+ },
87
+ {
88
+ "id": 7,
89
+ "naturalFit": "MK",
90
+ "dims": [
91
+ "OP",
92
+ "ST",
93
+ "MK",
94
+ "SA"
95
+ ]
96
+ },
97
+ {
98
+ "id": 8,
99
+ "naturalFit": "AN",
100
+ "dims": [
101
+ "FN",
102
+ "SA",
103
+ "MK",
104
+ "AN"
105
+ ]
106
+ },
107
+ {
108
+ "id": 9,
109
+ "naturalFit": "MK",
110
+ "dims": [
111
+ "MK",
112
+ "LD",
113
+ "SA",
114
+ "FN"
115
+ ]
116
+ },
117
+ {
118
+ "id": 10,
119
+ "naturalFit": "ST",
120
+ "dims": [
121
+ "OP",
122
+ "ST",
123
+ "LD",
124
+ "FN"
125
+ ]
126
+ },
127
+ {
128
+ "id": 11,
129
+ "naturalFit": "SA",
130
+ "dims": [
131
+ "FN",
132
+ "LD",
133
+ "SA",
134
+ "MK"
135
+ ]
136
+ },
137
+ {
138
+ "id": 12,
139
+ "naturalFit": "FN",
140
+ "dims": [
141
+ "AN",
142
+ "LD",
143
+ "MK",
144
+ "FN"
145
+ ]
146
+ },
147
+ {
148
+ "id": 13,
149
+ "naturalFit": "LD",
150
+ "dims": [
151
+ "LD",
152
+ "AN",
153
+ "MK",
154
+ "ST"
155
+ ]
156
+ },
157
+ {
158
+ "id": 14,
159
+ "naturalFit": "OP",
160
+ "dims": [
161
+ "FN",
162
+ "OP",
163
+ "LD",
164
+ "SA"
165
+ ]
166
+ },
167
+ {
168
+ "id": 15,
169
+ "naturalFit": "LD",
170
+ "dims": [
171
+ "FN",
172
+ "ST",
173
+ "LD",
174
+ "SA"
175
+ ]
176
+ },
177
+ {
178
+ "id": 16,
179
+ "naturalFit": "OP",
180
+ "dims": [
181
+ "FN",
182
+ "AN",
183
+ "MK",
184
+ "OP"
185
+ ]
186
+ },
187
+ {
188
+ "id": 17,
189
+ "naturalFit": "AN",
190
+ "dims": [
191
+ "AN",
192
+ "MK",
193
+ "ST",
194
+ "LD"
195
+ ]
196
+ },
197
+ {
198
+ "id": 18,
199
+ "naturalFit": "MK",
200
+ "dims": [
201
+ "OP",
202
+ "MK",
203
+ "FN",
204
+ "AN"
205
+ ]
206
+ },
207
+ {
208
+ "id": 19,
209
+ "naturalFit": "ST",
210
+ "dims": [
211
+ "SA",
212
+ "OP",
213
+ "ST",
214
+ "LD"
215
+ ]
216
+ },
217
+ {
218
+ "id": 20,
219
+ "naturalFit": "SA",
220
+ "dims": [
221
+ "FN",
222
+ "AN",
223
+ "OP",
224
+ "SA"
225
+ ]
226
+ },
227
+ {
228
+ "id": 21,
229
+ "naturalFit": "FN",
230
+ "dims": [
231
+ "FN",
232
+ "MK",
233
+ "OP",
234
+ "AN"
235
+ ]
236
+ },
237
+ {
238
+ "id": 22,
239
+ "naturalFit": "SA",
240
+ "dims": [
241
+ "LD",
242
+ "SA",
243
+ "ST",
244
+ "FN"
245
+ ]
246
+ },
247
+ {
248
+ "id": 23,
249
+ "naturalFit": "FN",
250
+ "dims": [
251
+ "OP",
252
+ "LD",
253
+ "FN",
254
+ "SA"
255
+ ]
256
+ },
257
+ {
258
+ "id": 24,
259
+ "naturalFit": "LD",
260
+ "dims": [
261
+ "AN",
262
+ "SA",
263
+ "OP",
264
+ "LD"
265
+ ]
266
+ },
267
+ {
268
+ "id": 25,
269
+ "naturalFit": "OP",
270
+ "dims": [
271
+ "OP",
272
+ "MK",
273
+ "ST",
274
+ "SA"
275
+ ]
276
+ },
277
+ {
278
+ "id": 26,
279
+ "naturalFit": "AN",
280
+ "dims": [
281
+ "OP",
282
+ "AN",
283
+ "LD",
284
+ "SA"
285
+ ]
286
+ },
287
+ {
288
+ "id": 27,
289
+ "naturalFit": "MK",
290
+ "dims": [
291
+ "AN",
292
+ "LD",
293
+ "MK",
294
+ "OP"
295
+ ]
296
+ },
297
+ {
298
+ "id": 28,
299
+ "naturalFit": "ST",
300
+ "dims": [
301
+ "OP",
302
+ "AN",
303
+ "MK",
304
+ "ST"
305
+ ]
306
+ },
307
+ {
308
+ "id": 29,
309
+ "naturalFit": "MK",
310
+ "dims": [
311
+ "MK",
312
+ "SA",
313
+ "ST",
314
+ "AN"
315
+ ]
316
+ },
317
+ {
318
+ "id": 30,
319
+ "naturalFit": "ST",
320
+ "dims": [
321
+ "AN",
322
+ "ST",
323
+ "LD",
324
+ "MK"
325
+ ]
326
+ },
327
+ {
328
+ "id": 31,
329
+ "naturalFit": "SA",
330
+ "dims": [
331
+ "AN",
332
+ "MK",
333
+ "SA",
334
+ "ST"
335
+ ]
336
+ },
337
+ {
338
+ "id": 32,
339
+ "naturalFit": "FN",
340
+ "dims": [
341
+ "SA",
342
+ "ST",
343
+ "AN",
344
+ "FN"
345
+ ]
346
+ },
347
+ {
348
+ "id": 33,
349
+ "naturalFit": "LD",
350
+ "dims": [
351
+ "LD",
352
+ "SA",
353
+ "MK",
354
+ "FN"
355
+ ]
356
+ },
357
+ {
358
+ "id": 34,
359
+ "naturalFit": "OP",
360
+ "dims": [
361
+ "LD",
362
+ "OP",
363
+ "MK",
364
+ "AN"
365
+ ]
366
+ },
367
+ {
368
+ "id": 35,
369
+ "naturalFit": "AN",
370
+ "dims": [
371
+ "SA",
372
+ "FN",
373
+ "AN",
374
+ "ST"
375
+ ]
376
+ },
377
+ {
378
+ "id": 36,
379
+ "naturalFit": "OP",
380
+ "dims": [
381
+ "ST",
382
+ "AN",
383
+ "SA",
384
+ "OP"
385
+ ]
386
+ },
387
+ {
388
+ "id": 37,
389
+ "naturalFit": "AN",
390
+ "dims": [
391
+ "AN",
392
+ "OP",
393
+ "ST",
394
+ "MK"
395
+ ]
396
+ },
397
+ {
398
+ "id": 38,
399
+ "naturalFit": "MK",
400
+ "dims": [
401
+ "LD",
402
+ "MK",
403
+ "FN",
404
+ "ST"
405
+ ]
406
+ },
407
+ {
408
+ "id": 39,
409
+ "naturalFit": "ST",
410
+ "dims": [
411
+ "FN",
412
+ "SA",
413
+ "ST",
414
+ "MK"
415
+ ]
416
+ },
417
+ {
418
+ "id": 40,
419
+ "naturalFit": "SA",
420
+ "dims": [
421
+ "LD",
422
+ "OP",
423
+ "AN",
424
+ "SA"
425
+ ]
426
+ },
427
+ {
428
+ "id": 41,
429
+ "naturalFit": "FN",
430
+ "dims": [
431
+ "FN",
432
+ "ST",
433
+ "SA",
434
+ "MK"
435
+ ]
436
+ },
437
+ {
438
+ "id": 42,
439
+ "naturalFit": "LD",
440
+ "dims": [
441
+ "OP",
442
+ "LD",
443
+ "FN",
444
+ "AN"
445
+ ]
446
+ }
447
+ ]
448
+ }
bsa/scoring.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Re-scores a Business Strength Archetype submission on the server, so what we
3
+ store never depends on numbers the browser sent. Mirrors lib/bsa/score.ts; the
4
+ question bank comes from questions.json, built by scripts/bsa/build_content.py.
5
+ """
6
+
7
+ import json
8
+ import math
9
+ from pathlib import Path
10
+
11
+ _DATA = json.loads((Path(__file__).resolve().parent / "questions.json").read_text(encoding="utf-8"))
12
+ ORDER: list[str] = _DATA["order"]
13
+ STAGES: list[str] = _DATA["stages"]
14
+ AGES: list[str] = _DATA["ages"]
15
+ QUESTIONS: dict[int, dict] = {q["id"]: q for q in _DATA["questions"]}
16
+
17
+
18
+ def percentages(raw: dict[str, int]) -> dict[str, int]:
19
+ """Whole percentages that add up to 100 (largest remainder method)."""
20
+ total = sum(raw.values()) or 1
21
+ exact = [raw[d] / total * 100 for d in ORDER]
22
+ pct = [math.floor(e) for e in exact]
23
+ order = sorted(range(len(ORDER)), key=lambda i: (-(exact[i] - pct[i]), i))
24
+ for i in order[: 100 - sum(pct)]:
25
+ pct[i] += 1
26
+ return dict(zip(ORDER, pct))
27
+
28
+
29
+ def score(answers: dict[int, str]) -> dict:
30
+ """answers: question id → strength picked. Raises ValueError if any answer is impossible."""
31
+ if set(answers) != set(QUESTIONS):
32
+ raise ValueError(f"Expected answers to all {len(QUESTIONS)} questions.")
33
+ raw = {d: 0 for d in ORDER}
34
+ fit = {d: 0 for d in ORDER}
35
+ for qid, dim in answers.items():
36
+ q = QUESTIONS[qid]
37
+ if dim not in q["dims"]:
38
+ raise ValueError(f"Question {qid} has no option for {dim}.")
39
+ raw[dim] += 1
40
+ if dim == q["naturalFit"]:
41
+ fit[dim] += 1
42
+ ranked = sorted(ORDER, key=lambda d: (-raw[d], -fit[d], ORDER.index(d)))
43
+ primary, secondary = ranked[0], ranked[1]
44
+ pair = sorted([primary, secondary], key=ORDER.index)
45
+ return {
46
+ "raw": raw,
47
+ "pct": percentages(raw),
48
+ "primary": primary,
49
+ "secondary": secondary,
50
+ "lowest": [ranked[6], ranked[5]],
51
+ "blendKey": f"{pair[0]}-{pair[1]}",
52
+ }
main.py CHANGED
@@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse
14
  import firebase_admin
15
  from firebase_admin import credentials
16
 
17
- from routes import bff, chat, transcribe, documents, consultation, planning, admin, account, help as help_routes, news
18
 
19
 
20
  # ─── Firebase Admin SDK initialisation ───────────────────────────────────────
@@ -93,6 +93,8 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin
93
  app.include_router(account.router, prefix="/api/account", tags=["Account"])
94
  app.include_router(help_routes.router, prefix="/api/help", tags=["Help"])
95
  app.include_router(news.router, prefix="/api/news", tags=["News"])
 
 
96
 
97
 
98
  # ─── Health check (unauthenticated) ──────────────────────────────────────────
 
14
  import firebase_admin
15
  from firebase_admin import credentials
16
 
17
+ from routes import bff, chat, transcribe, documents, consultation, planning, admin, account, help as help_routes, news, bsa as bsa_routes, bff_form
18
 
19
 
20
  # ─── Firebase Admin SDK initialisation ───────────────────────────────────────
 
93
  app.include_router(account.router, prefix="/api/account", tags=["Account"])
94
  app.include_router(help_routes.router, prefix="/api/help", tags=["Help"])
95
  app.include_router(news.router, prefix="/api/news", tags=["News"])
96
+ app.include_router(bsa_routes.router, prefix="/api/bsa", tags=["BSA"])
97
+ app.include_router(bff_form.router, prefix="/api/bff-form", tags=["BFF form"])
98
 
99
 
100
  # ─── Health check (unauthenticated) ──────────────────────────────────────────
routes/bff_form.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The public Business Footing Framework at /bff: the WHAT, WHY and HOW questions
3
+ from onboarding, with no sign-in, no AI and no follow-up questions.
4
+
5
+ The browser sends the answers and the person's contact details; this route
6
+ checks them and stores one document per submission in bff_submissions.
7
+ firestore.rules' default deny keeps the collection backend-only.
8
+ """
9
+
10
+ import time
11
+
12
+ from fastapi import APIRouter, HTTPException
13
+ from firebase_admin import firestore
14
+ from pydantic import BaseModel, Field
15
+
16
+ from routes.bff import BUSINESS_STAGES
17
+ from utils.business_type import describe
18
+ from utils.public_forms import RateLimiter, clean_email, clean_phone
19
+
20
+ router = APIRouter()
21
+
22
+ COLLECTION = "bff_submissions"
23
+ PER_SESSION_LIMIT = (5, 3600) # 5 submissions per hour per browser
24
+ GLOBAL_LIMIT = (500, 3600) # 500 per hour across everyone
25
+ limiter = RateLimiter()
26
+
27
+ Short = Field(default="", max_length=200)
28
+ Long = Field(default="", max_length=4000)
29
+ Choices = Field(default_factory=list, max_length=40)
30
+
31
+
32
+ class What(BaseModel):
33
+ businessStage: str = Field(max_length=40)
34
+ businessName: str = Field(min_length=1, max_length=120)
35
+ sectorId: str = Field(min_length=1, max_length=80)
36
+ subsectorId: str = Field(min_length=1, max_length=80)
37
+ nicheId: str = Field(min_length=1, max_length=80)
38
+ customNiche: str = Short
39
+ products: list[str] = Choices
40
+
41
+
42
+ class Why(BaseModel):
43
+ whyRaw: str = Field(min_length=1, max_length=4000)
44
+ valueProposition: str = Field(min_length=1, max_length=4000)
45
+
46
+
47
+ class Hours(BaseModel):
48
+ open: str = Field(max_length=5)
49
+ close: str = Field(max_length=5)
50
+ off: bool
51
+
52
+
53
+ class How(BaseModel):
54
+ locationType: str = Field(default="online", pattern="^(online|physical|hybrid)$")
55
+ state: str = Short
56
+ lga: str = Short
57
+ city: str = Short
58
+ address: str = Short
59
+ hours: dict[str, Hours] = Field(default_factory=dict, max_length=7)
60
+ demographics: dict[str, list[str]] = Field(default_factory=dict, max_length=12)
61
+ psychographics: str = Long
62
+ channels: dict[str, list[str]] = Field(default_factory=dict, max_length=6)
63
+ keyPartners: str = Long
64
+ revenueStreams: str = Long
65
+
66
+
67
+ class SubmissionIn(BaseModel):
68
+ name: str = Field(min_length=1, max_length=120)
69
+ email: str = Field(min_length=3, max_length=254)
70
+ phone: str = Field(min_length=1, max_length=30)
71
+ consent: bool
72
+ what: What
73
+ why: Why
74
+ how: How
75
+ startedAt: int = Field(ge=0) # ms since epoch, from the browser
76
+ sessionId: str = Field(min_length=1, max_length=64)
77
+
78
+
79
+ def _clean_list(items: list[str]) -> list[str]:
80
+ return [i.strip()[:200] for i in items if i.strip()]
81
+
82
+
83
+ @router.post("/submissions")
84
+ def submit(body: SubmissionIn):
85
+ if not limiter.allow(f"s:{body.sessionId}", *PER_SESSION_LIMIT) or not limiter.allow("global", *GLOBAL_LIMIT):
86
+ raise HTTPException(status_code=429, detail="Too many submissions. Try again later.")
87
+ if not body.consent:
88
+ raise HTTPException(status_code=400, detail="Consent is required to send your answers.")
89
+ email = clean_email(body.email)
90
+ phone = clean_phone(body.phone)
91
+ if body.what.businessStage not in BUSINESS_STAGES:
92
+ raise HTTPException(status_code=400, detail="Choose how far along your business is.")
93
+ if body.what.nicheId == "other" and not body.what.customNiche.strip():
94
+ raise HTTPException(status_code=400, detail="Tell us your niche.")
95
+
96
+ what = body.what.model_dump()
97
+ what["products"] = _clean_list(body.what.products)
98
+ how = body.how.model_dump()
99
+ how["demographics"] = {k[:40]: _clean_list(v) for k, v in body.how.demographics.items()}
100
+ how["channels"] = {k[:40]: _clean_list(v) for k, v in body.how.channels.items()}
101
+
102
+ now_ms = int(time.time() * 1000)
103
+ doc = {
104
+ "name": body.name.strip(),
105
+ "email": email,
106
+ "phone": phone,
107
+ "consent": True,
108
+ "what": what,
109
+ "why": {k: v.strip() for k, v in body.why.model_dump().items()},
110
+ "how": how,
111
+ # Readable labels next to the ids, so the collection can be read without the app.
112
+ "labels": {**describe(what), "stage": BUSINESS_STAGES[body.what.businessStage]},
113
+ "startedAt": body.startedAt,
114
+ "durationSeconds": max(0, (now_ms - body.startedAt) // 1000) if body.startedAt else None,
115
+ "submittedAt": firestore.SERVER_TIMESTAMP,
116
+ "version": 1,
117
+ }
118
+ try:
119
+ _, ref = firestore.client().collection(COLLECTION).add(doc)
120
+ except Exception:
121
+ raise HTTPException(status_code=500, detail="Could not save your answers.")
122
+ return {"success": True, "id": ref.id}
routes/bsa.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Business Strength Archetype results: the public assessment at /bsa.
3
+
4
+ No sign-in. The browser sends the person's details and the strength behind each
5
+ answer; this route re-scores the answers itself (bsa/scoring.py), then stores one
6
+ document per submission in bsa_results. firestore.rules' default deny keeps the
7
+ collection backend-only.
8
+ """
9
+
10
+ import time
11
+
12
+ from fastapi import APIRouter, HTTPException
13
+ from firebase_admin import firestore
14
+ from pydantic import BaseModel, Field
15
+
16
+ from bsa import scoring
17
+ from utils.public_forms import RateLimiter, clean_email, clean_phone
18
+
19
+ router = APIRouter()
20
+
21
+ COLLECTION = "bsa_results"
22
+ PER_SESSION_LIMIT = (5, 3600) # 5 submissions per hour per browser
23
+ GLOBAL_LIMIT = (500, 3600) # 500 per hour across everyone
24
+ limiter = RateLimiter()
25
+
26
+
27
+ class Answer(BaseModel):
28
+ q: int
29
+ dim: str = Field(min_length=2, max_length=2)
30
+
31
+
32
+ class ResultIn(BaseModel):
33
+ firstName: str = Field(min_length=1, max_length=60)
34
+ email: str = Field(min_length=3, max_length=254)
35
+ phone: str = Field(min_length=1, max_length=30)
36
+ ageRange: str = Field(max_length=40)
37
+ stage: str = Field(max_length=120)
38
+ field: str = Field(default="", max_length=120)
39
+ location: str = Field(default="", max_length=120)
40
+ consent: bool
41
+ answers: list[Answer] = Field(min_length=1, max_length=100)
42
+ startedAt: int = Field(ge=0) # ms since epoch, from the browser
43
+ sessionId: str = Field(min_length=1, max_length=64)
44
+
45
+
46
+ @router.post("/results")
47
+ def submit_result(body: ResultIn):
48
+ if not limiter.allow(f"s:{body.sessionId}", *PER_SESSION_LIMIT) or not limiter.allow("global", *GLOBAL_LIMIT):
49
+ raise HTTPException(status_code=429, detail="Too many submissions. Try again later.")
50
+ if not body.consent:
51
+ raise HTTPException(status_code=400, detail="Consent is required to save your result.")
52
+ email = clean_email(body.email)
53
+ phone = clean_phone(body.phone)
54
+ if body.ageRange not in scoring.AGES or body.stage not in scoring.STAGES:
55
+ raise HTTPException(status_code=400, detail="Choose your age range and business stage.")
56
+
57
+ answers = {a.q: a.dim for a in body.answers}
58
+ if len(answers) != len(body.answers):
59
+ raise HTTPException(status_code=400, detail="Each question can only be answered once.")
60
+ try:
61
+ result = scoring.score(answers)
62
+ except ValueError as e:
63
+ raise HTTPException(status_code=400, detail=str(e))
64
+
65
+ now_ms = int(time.time() * 1000)
66
+ doc = {
67
+ "firstName": body.firstName.strip(),
68
+ "email": email,
69
+ "phone": phone,
70
+ "ageRange": body.ageRange,
71
+ "stage": body.stage,
72
+ "field": body.field.strip(),
73
+ "location": body.location.strip(),
74
+ "consent": True,
75
+ "answers": [{"q": q, "dim": d} for q, d in sorted(answers.items())],
76
+ **result,
77
+ "startedAt": body.startedAt,
78
+ "durationSeconds": max(0, (now_ms - body.startedAt) // 1000) if body.startedAt else None,
79
+ "finishedAt": firestore.SERVER_TIMESTAMP,
80
+ "version": 1,
81
+ }
82
+ try:
83
+ _, ref = firestore.client().collection(COLLECTION).add(doc)
84
+ except Exception:
85
+ raise HTTPException(status_code=500, detail="Could not save your result.")
86
+ return {"success": True, "id": ref.id, **result}
tests/test_bff_form.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the public Business Footing Framework route (routes/bff_form.py)
3
+ and the shared public-form checks (utils/public_forms.py). Firestore is mocked.
4
+ """
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+ from fastapi import HTTPException
9
+
10
+ from routes import bff_form
11
+ from routes.bff_form import SubmissionIn, submit
12
+ from utils.public_forms import clean_phone
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _reset_limits():
17
+ bff_form.limiter.reset()
18
+
19
+
20
+ def _body(**over):
21
+ base = dict(
22
+ name=" Ada Obi ", email=" Ada@Example.com ", phone="0803 123 4567", consent=True,
23
+ what=dict(businessStage="early", businessName="Ada Books", sectorId="retail_trade",
24
+ subsectorId="bookshops", nicheId="other", customNiche="Children's books",
25
+ products=["Story books", " ", "School texts"]),
26
+ why=dict(whyRaw=" I love reading ", valueProposition="Good books at fair prices"),
27
+ how=dict(locationType="physical", state="Lagos", city="Ikeja",
28
+ hours={"monday": {"open": "09:00", "close": "18:00", "off": False}},
29
+ demographics={"ageRanges": ["21–25"]}, channels={"social_media": ["Instagram"]},
30
+ revenueStreams="Book sales"),
31
+ startedAt=1, sessionId="s-1",
32
+ )
33
+ base.update(over)
34
+ return SubmissionIn(**base)
35
+
36
+
37
+ def _db():
38
+ db = MagicMock()
39
+ db.collection.return_value.add.return_value = (None, MagicMock(id="doc1"))
40
+ return db
41
+
42
+
43
+ def test_stores_answers_contact_and_labels():
44
+ db = _db()
45
+ with patch.object(bff_form.firestore, "client", return_value=db):
46
+ out = submit(_body())
47
+ assert out == {"success": True, "id": "doc1"}
48
+ db.collection.assert_called_with("bff_submissions")
49
+ doc = db.collection.return_value.add.call_args[0][0]
50
+ assert (doc["name"], doc["email"], doc["phone"]) == ("Ada Obi", "ada@example.com", "0803 123 4567")
51
+ assert doc["what"]["products"] == ["Story books", "School texts"]
52
+ assert doc["why"]["whyRaw"] == "I love reading"
53
+ assert doc["labels"]["niche"] == "Children's books" and doc["labels"]["stage"].startswith("Running for 1")
54
+ assert doc["how"]["hours"]["monday"]["open"] == "09:00"
55
+
56
+
57
+ @pytest.mark.parametrize("over", [
58
+ {"consent": False}, {"email": "nope"}, {"phone": "12345"},
59
+ {"what": dict(businessStage="unknown", businessName="X", sectorId="a", subsectorId="b", nicheId="c")},
60
+ {"what": dict(businessStage="idea", businessName="X", sectorId="a", subsectorId="b", nicheId="other")},
61
+ ])
62
+ def test_rejects_bad_submissions(over):
63
+ with pytest.raises(HTTPException) as e:
64
+ submit(_body(**over))
65
+ assert e.value.status_code == 400
66
+
67
+
68
+ def test_rate_limits_per_browser():
69
+ with patch.object(bff_form.firestore, "client", return_value=_db()):
70
+ for _ in range(5):
71
+ submit(_body())
72
+ with pytest.raises(HTTPException) as e:
73
+ submit(_body())
74
+ assert e.value.status_code == 429
75
+
76
+
77
+ @pytest.mark.parametrize("raw,ok", [
78
+ ("+234 803 123 4567", True), ("(0803) 123-4567", True), ("08031234567", True),
79
+ ("123", False), ("0803 abc 4567", False), ("+1234567890123456", False),
80
+ ])
81
+ def test_phone_check(raw, ok):
82
+ if ok:
83
+ assert clean_phone(raw) == " ".join(raw.split())
84
+ else:
85
+ with pytest.raises(HTTPException):
86
+ clean_phone(raw)
tests/test_bsa.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the Business Strength Archetype: server-side scoring (bsa/scoring.py)
3
+ and the public results route (routes/bsa.py). Firestore is mocked.
4
+ """
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+ from fastapi import HTTPException
9
+
10
+ from bsa import scoring
11
+ from routes import bsa as bsa_routes
12
+ from routes.bsa import ResultIn, submit_result
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _reset_limits():
17
+ bsa_routes.limiter.reset()
18
+
19
+
20
+ def _natural():
21
+ return {qid: q["naturalFit"] for qid, q in scoring.QUESTIONS.items()}
22
+
23
+
24
+ def _body(answers=None, **over):
25
+ answers = answers or _natural()
26
+ base = dict(firstName=" Ada ", email=" Ada@Example.com ", phone=" +234 803 123 4567 ", ageRange=scoring.AGES[1], stage=scoring.STAGES[2],
27
+ consent=True, answers=[{"q": q, "dim": d} for q, d in answers.items()],
28
+ startedAt=1, sessionId="s-1")
29
+ base.update(over)
30
+ return ResultIn(**base)
31
+
32
+
33
+ def test_scoring_matches_the_documented_rules():
34
+ r = scoring.score(_natural())
35
+ assert set(r["raw"].values()) == {6}
36
+ assert (r["primary"], r["secondary"], r["blendKey"]) == ("ST", "AN", "ST-AN")
37
+ assert sum(r["pct"].values()) == 100
38
+
39
+ op = {qid: ("OP" if "OP" in q["dims"] else q["dims"][0]) for qid, q in scoring.QUESTIONS.items()}
40
+ r = scoring.score(op)
41
+ assert r["primary"] == "OP" and r["raw"]["OP"] == 24
42
+
43
+
44
+ def test_rejects_impossible_or_missing_answers():
45
+ answers = _natural()
46
+ first = min(answers)
47
+ bad = next(d for d in scoring.ORDER if d not in scoring.QUESTIONS[first]["dims"])
48
+ with pytest.raises(ValueError):
49
+ scoring.score({**answers, first: bad})
50
+ answers.pop(first)
51
+ with pytest.raises(ValueError):
52
+ scoring.score(answers)
53
+
54
+
55
+ def test_stores_details_and_server_computed_score():
56
+ db = MagicMock()
57
+ db.collection.return_value.add.return_value = (None, MagicMock(id="doc1"))
58
+ with patch.object(bsa_routes.firestore, "client", return_value=db):
59
+ out = submit_result(_body())
60
+ assert out["id"] == "doc1" and out["blendKey"] == "ST-AN"
61
+ db.collection.assert_called_with("bsa_results")
62
+ stored = db.collection.return_value.add.call_args[0][0]
63
+ assert stored["firstName"] == "Ada" and stored["email"] == "ada@example.com"
64
+ assert stored["phone"] == "+234 803 123 4567"
65
+ assert len(stored["answers"]) == 42 and stored["primary"] == "ST"
66
+
67
+
68
+ @pytest.mark.parametrize("over", [{"consent": False}, {"email": "not-an-email"}, {"phone": "call me maybe"}, {"phone": "123456"}, {"stage": "made up"}])
69
+ def test_rejects_bad_details(over):
70
+ with pytest.raises(HTTPException) as e:
71
+ submit_result(_body(**over))
72
+ assert e.value.status_code == 400
73
+
74
+
75
+ def test_rate_limits_per_browser():
76
+ db = MagicMock()
77
+ db.collection.return_value.add.return_value = (None, MagicMock(id="x"))
78
+ with patch.object(bsa_routes.firestore, "client", return_value=db):
79
+ for _ in range(5):
80
+ submit_result(_body())
81
+ with pytest.raises(HTTPException) as e:
82
+ submit_result(_body())
83
+ assert e.value.status_code == 429
utils/public_forms.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared checks for the public, no-sign-in forms (/bsa and /bff): a simple rate
3
+ limiter and the contact-detail validators. Rate limits are in-memory, per
4
+ process (same trade-off as routes/help.py).
5
+ """
6
+
7
+ import re
8
+ import threading
9
+ import time
10
+ from collections import defaultdict
11
+
12
+ from fastapi import HTTPException
13
+
14
+ EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
15
+ # Digits with an optional leading +, allowing the spaces, dashes, dots and brackets people type.
16
+ PHONE_RE = re.compile(r"^\+?[\d\s().-]+$")
17
+
18
+
19
+ class RateLimiter:
20
+ """Allows `count` hits per `period` seconds for each key."""
21
+
22
+ def __init__(self):
23
+ self._lock = threading.Lock()
24
+ self._hits: dict[str, list[float]] = defaultdict(list)
25
+
26
+ def allow(self, key: str, count: int, period: int) -> bool:
27
+ now = time.time()
28
+ with self._lock:
29
+ recent = [t for t in self._hits[key] if now - t < period]
30
+ allowed = len(recent) < count
31
+ if allowed:
32
+ recent.append(now)
33
+ self._hits[key] = recent
34
+ return allowed
35
+
36
+ def reset(self):
37
+ with self._lock:
38
+ self._hits.clear()
39
+
40
+
41
+ def clean_email(raw: str) -> str:
42
+ email = raw.strip().lower()
43
+ if not EMAIL_RE.match(email):
44
+ raise HTTPException(status_code=400, detail="Enter a valid email address.")
45
+ return email
46
+
47
+
48
+ def clean_phone(raw: str) -> str:
49
+ """Trimmed phone number, kept as typed apart from spacing. 7 to 15 digits."""
50
+ phone = " ".join(raw.split())
51
+ digits = re.sub(r"\D", "", phone)
52
+ if not PHONE_RE.match(phone) or not 7 <= len(digits) <= 15:
53
+ raise HTTPException(status_code=400, detail="Enter a valid phone number.")
54
+ return phone