From 9fd748154d0006cc8c9bf4192ff2afabacd21496 Mon Sep 17 00:00:00 2001 From: Macky Date: Fri, 21 Aug 2026 12:28:22 +0700 Subject: [PATCH] feat: UX/SAAS 12-point redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth: self-registration (role=user); first-created-user becomes super_admin - roles: super_admin may promote others; regular admin cannot see super_admin accounts - products: user-created private groups; admin 'สินค้าขององค์กร' (shared) with hidden/public; users can create groups - analytics: team + per-user weak areas, close-rate-by-difficulty buckets, 30-day default, weekly trend, trainee table, active users; dashboard redesigned - files: docx + xlsx upload support (python-docx + openpyxl) - ui: tabs การฝึก→ผลการฝึก→ภาพรวม; admin lands on ภาพรวม / user on การฝึก; guide in topbar - consolidate: weak-areas merged into Results (10/page), my-personas merged into Training - copy: บุคคลต้นแบบ→persona everywhere; clearer add-product form (A/B/C, upload-or-fill) - report: remove ดูรายงาน UI entry (endpoint kept) Backend 348 tests pass; frontend build + vitest clean. --- backend/app/api/admin_routes.py | 30 ++- backend/app/api/analytics_routes.py | 184 ++++++++++++++- backend/app/api/auth_routes.py | 101 +++++++- backend/app/api/chat_routes.py | 20 +- backend/app/api/group_routes.py | 67 +++++- backend/app/config.py | 4 +- backend/app/models/entities.py | 3 + backend/app/services/file_parser.py | 116 +++++++++ backend/app/services/groups.py | 28 ++- backend/app/services/trainee.py | 62 +++-- backend/requirements.lock.txt | 221 ++++++++++++++++-- backend/requirements.txt | 2 + backend/tests/test_sprint1_review_findings.py | 64 +++-- backend/tests/test_upload_security.py | 2 +- docs/HANDOFF.md | 11 + docs/engineering-log.md | 1 + .../2026-08-21-ux-saas-redesign.md | 101 ++++++++ docs/plan-2026-08-21-ux-saas-redesign.md | 136 +++++++++++ frontend/src/App.vue | 34 ++- frontend/src/i18n/index.js | 122 ++++++---- frontend/src/router.spec.js | 6 +- frontend/src/router/index.js | 23 +- frontend/src/views/Analytics.vue | 194 +++++++++++---- frontend/src/views/GroupBuilder.vue | 69 +++++- frontend/src/views/GroupEdit.vue | 6 +- frontend/src/views/GroupReport.vue | 71 ------ frontend/src/views/Guide.vue | 28 +-- frontend/src/views/Login.vue | 10 +- frontend/src/views/MyBoard.vue | 102 +++++++- frontend/src/views/MyPersonas.vue | 84 ------- frontend/src/views/Personas.vue | 2 +- frontend/src/views/Training.vue | 196 +++++++++++++--- frontend/src/views/WeakAreas.vue | 78 ------- 33 files changed, 1674 insertions(+), 504 deletions(-) create mode 100644 docs/engineering-log/2026-08-21-ux-saas-redesign.md create mode 100644 docs/plan-2026-08-21-ux-saas-redesign.md delete mode 100644 frontend/src/views/GroupReport.vue delete mode 100644 frontend/src/views/MyPersonas.vue delete mode 100644 frontend/src/views/WeakAreas.vue diff --git a/backend/app/api/admin_routes.py b/backend/app/api/admin_routes.py index 2974296..ded5a49 100644 --- a/backend/app/api/admin_routes.py +++ b/backend/app/api/admin_routes.py @@ -151,9 +151,11 @@ def create_user(): raise internal_error("user could not be created", exc, 400) if role not in Config.ROLES: raise ApiError(f"invalid role: {role}") - # Super-admin accounts are bootstrap/platform identities, not tenant-provisioned users. - if role == "super_admin": - raise ApiError("super_admin accounts are provisioned only by bootstrap", 403) + # Super-admin accounts are bootstrap/platform identities. A super_admin may + # provision another super_admin (trust-based promotion); any other actor may + # not. Admin grants are super_admin-only (kept below). + if role == "super_admin" and actor_role != "super_admin": + raise ApiError("only super_admin can grant super_admin roles", 403) if role == "admin" and actor_role != "super_admin": raise ApiError("only super_admin can grant admin roles", 403) @@ -200,7 +202,12 @@ def list_users(): if actor.get("role") == "super_admin": users = _store().list_users() else: - users = _store().list_users(org_id=actor.get("org_id")) + # A regular tenant admin must NOT see super_admin accounts (invisible + # platform identities). Filter them from the org scoped list. + users = [ + u for u in _store().list_users(org_id=actor.get("org_id")) + if u.get("role") != "super_admin" + ] return jsonify({"users": users}) @@ -222,12 +229,17 @@ def update_user(username: str): ): raise ApiError("user not found", 404) - if target.get("role") == "super_admin": + # Only a super_admin may manage super_admin accounts (promotion or edits). + # A regular admin already can't reach here for a super_admin target (404 above). + if target.get("role") == "super_admin" and actor.get("role") != "super_admin": raise ApiError("super_admin accounts cannot be changed through admin API", 403) - # Role changes / admin-modification restricted to super_admin + # Role changes / admin-modification restricted to super_admin. Only super_admin + # may promote to (or demote from) admin/super_admin status; a regular admin + # may only ever manage plain users. updates: dict[str, object] = {} - if "role" in data: + role_change_requested = "role" in data + if role_change_requested: role_raw = data.get("role") if not isinstance(role_raw, str): raise ApiError("role must be a string", 400) @@ -236,8 +248,8 @@ def update_user(username: str): raise ApiError(f"invalid role: {role}") if actor.get("role") != "super_admin": raise ApiError("permission denied", 403) - if role == "super_admin": - raise ApiError("super_admin accounts are provisioned only by bootstrap", 403) + if role in ("super_admin", "admin") and actor.get("role") != "super_admin": + raise ApiError("only super_admin can grant admin/super_admin roles", 403) updates["role"] = role if "active" in data: diff --git a/backend/app/api/analytics_routes.py b/backend/app/api/analytics_routes.py index 79a71bb..5db1853 100644 --- a/backend/app/api/analytics_routes.py +++ b/backend/app/api/analytics_routes.py @@ -85,6 +85,46 @@ def _is_finished_outcome(session: dict) -> bool: ) +def _top_weak_area_label(sessions: list[dict]) -> str | None: + """Return the dominant weak-area dimension label for a trainee's sessions. + + Runs the per-user weak-area analysis over an already user-scoped, finished + trainee session list and returns the highest-evidence dimension label. + """ + from ..services.trainee import analyze_weak_areas + + try: + user_ids = {s.get("user_id") for s in sessions if isinstance(s, dict) and isinstance(s.get("user_id"), str)} + if not user_ids: + return None + uid = next(iter(user_ids)) + if not isinstance(uid, str): + return None + org_ids = {s.get("org_id") for s in sessions if isinstance(s, dict) and isinstance(s.get("org_id"), str)} + org_id = next(iter(org_ids)) if org_ids else "" + analysis = analyze_weak_areas(sessions, user_id=uid, org_id=org_id or "org-default") + except Exception: + return None + dimensions = analysis.get("dimensions") or {} + best_key = None + best_count = 0 + for key, dim in dimensions.items(): + if not isinstance(dim, dict): + continue + try: + count = len(dim.get("session_ids") or []) + except TypeError: + count = 0 + if count > best_count: + best_count = count + best_key = key + if best_key is None: + return None + dim = dimensions.get(best_key) or {} + label = dim.get("label") if isinstance(dim, dict) else None + return label or best_key + + def _is_safe_export_identity(value: object) -> bool: if not isinstance(value, str): return False @@ -338,17 +378,31 @@ def analytics(): s = _stores() actor = current_user() - # Date filter (optional) from ?from=YYYY-MM-DD&to=YYYY-MM-DD on created_at + # Date filter (optional) from ?from=YYYY-MM-DD&to=YYYY-MM-DD on created_at. + # Default window = last 30 days (today-30 .. today) when neither bound is given. date_from = _parse_date_iso(request.args.get("from")) date_to = _parse_date_iso(request.args.get("to"), end=True) + if date_from is None and date_to is None: + today = datetime.date.today() + date_from = datetime.datetime.combine( + today - datetime.timedelta(days=30), datetime.time(0, 0, 0), + tzinfo=datetime.timezone.utc, + ).isoformat() + date_to = datetime.datetime.combine( + today, datetime.time(23, 59, 59, 999999), tzinfo=datetime.timezone.utc, + ).isoformat() + if date_from: + date_from = date_from[:19] + if date_to: + date_to = date_to[:19] def _in_window(sess) -> bool: created = (sess.get("created_at") or "")[:19] if not created: return True - if date_from and created < date_from[:19]: + if date_from and created < date_from: return False - if date_to and created > date_to[:19]: + if date_to and created > date_to: return False return True @@ -417,7 +471,120 @@ def analytics(): scores = [_debrief_score(x) for x in sessions] overall["avg_score"] = round(sum(scores) / len(scores), 1) if scores else 0 - # hardest personas = personas with most losses (lowest avg score) + # ── difficulty bucket lookup from group persona records ────────────── + # Map (group_id, persona_id) -> difficulty for every relevant group so each + # session's bucket can be read without per-row store lookups. + from ..services.trainee import analyze_team_weak_areas + + persona_difficulty: dict[tuple[str, str], int] = {} + for group_candidate in s["groups"].groups.all(): + if not isinstance(group_candidate, dict) or not isinstance(group_candidate.get("id"), str): + continue + gid = group_candidate["id"] + raw_personas = group_candidate.get("personas") + personas = raw_personas if isinstance(raw_personas, list) else [] + for p in personas: + if not isinstance(p, dict) or not isinstance(p.get("id"), str): + continue + diff = p.get("difficulty") + persona_difficulty[(gid, p["id"])] = diff if isinstance(diff, int) else 5 + + def _bucket(diff: int) -> str: + if diff <= 2: + return "easy" + if diff == 3: + return "medium" + return "hard" + + _BUCKET_META = { + "easy": {"label": "ง่าย", "range": "1-2"}, + "medium": {"label": "กลาง", "range": "3"}, + "hard": {"label": "ยาก", "range": "4-5"}, + } + close_by_difficulty = { + key: {"plays": 0, "wins": 0, "losses": 0, "close_rate": 0, **meta} + for key, meta in _BUCKET_META.items() + } + for x in sessions: + gid_ = x.get("group_id") + pid_ = x.get("persona_id") + diff = persona_difficulty.get( + (gid_ if isinstance(gid_, str) else "", pid_ if isinstance(pid_, str) else ""), 5 + ) + rec = close_by_difficulty[_bucket(diff)] + rec["plays"] += 1 + if x.get("outcome") == "won": + rec["wins"] += 1 + elif x.get("outcome") == "lost": + rec["losses"] += 1 + for rec in close_by_difficulty.values(): + rec["close_rate"] = round(rec["wins"] / rec["plays"] * 100, 1) if rec["plays"] else 0 + + # ── active users ──────────────────────────────────────────────────── + active_user_ids = {x.get("user_id") for x in sessions if isinstance(x.get("user_id"), str)} + active_users = len(active_user_ids) + + # username map for the per-trainee table + username_of = {u.get("id"): u.get("username") or u.get("id") + for u in users if isinstance(u, dict) and isinstance(u.get("id"), str)} + + # ── weekly trend (ISO week) ────────────────────────────────────────── + def _iso_week(created: object) -> str: + if not isinstance(created, str) or not created[:10]: + return "unknown" + try: + parsed = datetime.date.fromisoformat(created[:10]) + return f"{parsed.isocalendar()[0]}-W{parsed.isocalendar()[1]:02d}" + except ValueError: + return "unknown" + + weekly: dict[str, dict] = {} + for x in sessions: + wk = _iso_week(x.get("created_at")) + entry = weekly.setdefault(wk, {"week": wk, "sessions": 0, "wins": 0}) + entry["sessions"] += 1 + if x.get("outcome") == "won": + entry["wins"] += 1 + weekly_trend = [ + weekly[key] for key in sorted(weekly, key=lambda w: w.split("-W")[-1] if "-W" in w else w) + ] + + # ── per-trainee table ──────────────────────────────────────────────── + user_sessions: dict[str, list[dict]] = {} + per_user: dict[str, dict] = {} + for x in sessions: + uid = x.get("user_id") + if not isinstance(uid, str): + continue + user_sessions.setdefault(uid, []).append(x) + rec = per_user.setdefault( + uid, {"username": username_of.get(uid, uid), "plays": 0, "wins": 0, "losses": 0, "scores": []} + ) + rec["plays"] += 1 + rec["scores"].append(_debrief_score(x)) + if x.get("outcome") == "won": + rec["wins"] += 1 + elif x.get("outcome") == "lost": + rec["losses"] += 1 + trainee_table = [] + for uid, rec in per_user.items(): + rec["close_rate"] = round(rec["wins"] / rec["plays"] * 100, 1) if rec["plays"] else 0 + rec["avg_score"] = round(sum(rec["scores"]) / len(rec["scores"]), 1) if rec["scores"] else 0 + user_weak = _top_weak_area_label(user_sessions.get(uid, [])) + trainee_table.append( + { + "username": rec["username"], + "plays": rec["plays"], + "wins": rec["wins"], + "losses": rec["losses"], + "close_rate": rec["close_rate"], + "avg_score": rec["avg_score"], + "top_weak_area": user_weak, + } + ) + trainee_table.sort(key=lambda r: (-r["plays"], r["username"])) + + # ── hardest personas = personas with most losses (lowest avg score) ── by_persona: dict = {} for x in sessions: key = (x.get("group_id"), x.get("persona_id"), x.get("persona_name", "?")) @@ -444,10 +611,19 @@ def analytics(): key=lambda r: (-r["losses"], r["avg_score"]), )[:10] + # ── team weak areas ────────────────────────────────────────────────── + team_org = actor.get("org_id") or "org-default" + team_weak = analyze_team_weak_areas(sessions, org_id=team_org) + return jsonify({ "overall": overall, "trainee_count": trainee_count, + "active_users": active_users, + "weekly_trend": weekly_trend, + "close_by_difficulty": close_by_difficulty, + "trainee_table": trainee_table, "hardest_personas": hardest, + "team_weak_areas": team_weak, }) diff --git a/backend/app/api/auth_routes.py b/backend/app/api/auth_routes.py index c23a31d..0a6c269 100644 --- a/backend/app/api/auth_routes.py +++ b/backend/app/api/auth_routes.py @@ -1,10 +1,16 @@ -"""Auth routes: login, current user, first-time admin setup. No self-registration.""" +"""Auth routes: login, current user, first-time admin setup, self-registration. + +Self-registration creates a default-role (``user``) seat-checked account in a +default org. When the platform has zero users, the first registered user is +promoted to ``super_admin`` automatically (global bootstrap). +""" from __future__ import annotations from flask import Blueprint, jsonify, request -from ..auth.users import AuthError, SetupAlreadyCompletedError -from .helpers import ApiError, current_user, request_json_object, require_auth +from ..auth.users import AuthError, SetupAlreadyCompletedError, is_valid_tenant_id +from ..config import Config +from .helpers import ApiError, current_user, internal_error, request_json_object, require_auth auth_bp = Blueprint("auth", __name__) @@ -15,6 +21,28 @@ def _store(): return current_app.extensions["user_store"] +def _ensure_register_org(): + """Return an active org for self-registration (mirror OAuth default-org). + + Prefer ``OAUTH_DEFAULT_ORG`` when configured; otherwise fall back to the + platform default org. The org is created once (locked) if missing so new + signups pass the SaaS tenant gate. Seats are enforced by create_user. + """ + store = _store() + org_id = Config.OAUTH_DEFAULT_ORG or "org-default" + if not is_valid_tenant_id(org_id): + raise ApiError("registration is unavailable", 503) + org = store.get_org_or_none(org_id) + if org is None: + with store.orgs.record_lock(org_id): + org = store.orgs.get_or_none(org_id) + if org is None: + org = store.create_org("Public Signups", org_id=org_id) + if org is None or org.get("active") is not True: + raise ApiError("registration is unavailable", 503) + return org_id + + def _login_body(data: dict) -> str: # Accept `username` (primary) or `email` (fallback), lower-cased. ident = data.get("username") @@ -52,6 +80,73 @@ def login(): }) +@auth_bp.post("/register") +def register(): + """Self-service registration: username + password + email, default role=user. + + Seat-checked, lands in the registration default org. When the platform has + zero users, the first registered user is auto-promoted to ``super_admin`` + (global bootstrap); otherwise the role stays ``user`` and is never super. + """ + data = request_json_object(allow_empty=True) + username = data.get("username") + password = data.get("password") + email = data.get("email") + if not isinstance(username, str) or not username.strip(): + raise ApiError("username is required", 400) + if not isinstance(password, str) or not password: + raise ApiError("password is required", 400) + if not isinstance(email, str) or not email.strip(): + raise ApiError("email is required", 400) + + from ..services.rate_limit import check as ratelimit + + client_ip = request.remote_addr or "?" + if not ratelimit("register:ip", client_ip, limit=10, window=300): + raise ApiError("too many attempts, try again later", 429) + ident = (username or "").strip().lower() + if not ratelimit("register:user", ident, limit=5, window=300): + raise ApiError("too many attempts, try again later", 429) + + store = _store() + org_id = _ensure_register_org() + + # First-created-user rule: an empty user store promotes the first account to + # super_admin (global bootstrap). All subsequent accounts default to user. + # The check-to-create must be atomic so two concurrent workers cannot both + # see an empty store; the collection lock inside create_user serializes it, + # so we set the role based on emptiness and rely on create_user's uniqueness. + promote_first = len(store.users.all()) == 0 + + try: + user = store.create_user( + org_id=org_id, + username=username, + password=password, + name=username, + role="super_admin" if promote_first else "user", + email=email, + must_setup=False, + ) + token = store.issue_token(user) + except AuthError as exc: + # Generic; never leak why (seats / uniqueness / inactive org / invalid input). + if str(exc) in ( + "organization seat limit reached", + "organization has no available seats", + ): + raise ApiError("registration is full", 422) + raise internal_error("registration failed", exc, 400) + except (OSError, TypeError, ValueError, UnicodeError, OverflowError) as exc: + raise internal_error("registration failed", exc, 503) + + return jsonify({ + "token": token, + "user": store.public_user(user), + "must_setup": user.get("must_setup") is True, + }), 201 + + @auth_bp.get("/me") @require_auth def me(): diff --git a/backend/app/api/chat_routes.py b/backend/app/api/chat_routes.py index d3ff8ac..340440e 100644 --- a/backend/app/api/chat_routes.py +++ b/backend/app/api/chat_routes.py @@ -250,7 +250,9 @@ def _authorize_session_context( actor = current_user() actor_id = actor.get("id") org_id = actor.get("org_id") - expected_mode = "trainee" # no preview mode; all sessions are one-shot trainee + # Both trainee (one-shot) and admin preview sessions are authorized here; + # the group context re-check below still locks out non-owners/foreign orgs. + expected_modes = ("trainee", "preview") session_mode = session.get("mode") if session_mode is None: session_mode = "trainee" # legacy rows before explicit mode was added @@ -264,7 +266,7 @@ def _authorize_session_context( or not org_id or session.get("user_id") != actor_id or session.get("org_id") != org_id - or session_mode != expected_mode + or session_mode not in expected_modes or not isinstance(session_gid, str) or not session_gid or not isinstance(session_pid, str) @@ -306,10 +308,16 @@ def start_session(gid: str, pid: str): requested_mode = body.get("mode", "trainee") if requested_mode not in ("trainee", "preview"): raise ApiError("session mode is invalid", 400) - # No preview mode: every role trains for real. All sessions are one-shot - # trainee attempts (1 persona chat per user), so admins/super_admins are - # subject to the same per-user lock as trainees when they use the app. - if requested_mode == "preview": + # Preview mode exists so an admin can safely try a HIDDEN (draft) shared + # product without consuming a trainee attempt or polluting trainee + # analytics. For any other group/role, requesting 'preview' is coerced to a + # real one-shot trainee session (previous behavior). + group_visibility = (group.get("visibility") or "public") + if requested_mode == "preview" and not ( + actor.get("role") == "admin" + and group_visibility == "hidden" + and "owner_user_id" not in group + ): requested_mode = "trainee" scenario_raw = body.get("scenario", "social") scenario = scenario_raw.strip().lower() if isinstance(scenario_raw, str) else "social" diff --git a/backend/app/api/group_routes.py b/backend/app/api/group_routes.py index 283b584..2e49570 100644 --- a/backend/app/api/group_routes.py +++ b/backend/app/api/group_routes.py @@ -63,7 +63,7 @@ ADMIN_EDITABLE_PERSONA_FIELDS = ADMIN_VISIBLE_PERSONA_FIELDS - {"id"} # internal fields must be deliberately added here before they can cross the API. GROUP_VISIBLE_FIELDS = { "id", "org_id", "creator_id", "owner_user_id", "title", "status", - "created_at", "updated_at", "input", "sales_kit", "personas", "report", "error", + "visibility", "created_at", "updated_at", "input", "sales_kit", "personas", "report", "error", } @@ -170,8 +170,15 @@ def _get_owned_group(s, gid: str) -> dict: if not isinstance(group, dict): raise ApiError("group state unavailable", 503) _authorize_group(group) - if current_user().get("role") == "user" and group.get("status") != "ready": - raise ApiError("group not ready", 403) + if current_user().get("role") == "user": + # A trainee may not access a shared HIDDEN group directly; hidden + # products are admin-preview only. (Public shared groups stay reachable + # for trainees, e.g. to spawn private persona variants; their own + # private groups require the ready gate below.) + if "owner_user_id" not in group and (group.get("visibility") or "public") == "hidden": + raise ApiError("permission denied", 403) + if group.get("status") != "ready": + raise ApiError("group not ready", 403) return group @@ -249,9 +256,14 @@ def _validate_upload_budget( @groups_bp.post("") @require_auth -@require_roles("admin") +@require_roles("user", "admin") def create_group(): - """Create a persona group from a setup form + optional files.""" + """Create a persona group from a setup form + optional files. + + A ``user`` (trainee) creates a PRIVATE group owned by themselves + (owner_user_id = self). An admin creates an org-shared product + (สินค้าขององค์กร) with a visibility of public (default) or hidden. + """ s = _stores() file_text = "" saved_files = [] @@ -357,15 +369,33 @@ def create_group(): product = string_fields["product"] if product == "" and not file_text.strip(): raise ApiError("provide product info in the form or via file upload") - actor_org_id = current_user().get("org_id") + actor = current_user() + actor_org_id = actor.get("org_id") if not is_valid_tenant_id(actor_org_id): raise ApiError("permission denied", 403) + is_trainee = actor.get("role") == "user" + + # Trainees always create a PRIVATE group owned by themselves. Admins + # create org-shared products with an explicit visibility (default public). + visibility_raw = data.get("visibility") + if is_trainee: + owner_user_id = actor.get("id") + visibility = "private" + else: + owner_user_id = None + if visibility_raw is not None and not isinstance(visibility_raw, str): + raise ApiError("visibility must be a string", 400) + visibility = (visibility_raw or "public").strip() + if visibility not in ("public", "hidden"): + raise ApiError("visibility must be 'public' or 'hidden'") try: group = s["groups"].create( org_id=actor_org_id, - creator_id=current_user()["id"], + creator_id=actor["id"], title=(product or file_text[:80] or "Untitled group").strip()[:200], + owner_user_id=owner_user_id, + visibility=visibility, ) s["groups"].update( group["id"], @@ -421,6 +451,7 @@ def list_groups(): "id": g.get("id"), "title": g.get("title", ""), "status": g.get("status", "draft"), + "visibility": g.get("visibility", "public"), "channel": group_input.get("channel"), "org_id": g.get("org_id"), "persona_count": len(personas), @@ -431,12 +462,28 @@ def list_groups(): @groups_bp.post("//analyze") @require_auth -@require_roles("admin") +@require_roles("user", "admin") @_group_analysis_lock def analyze_group(gid: str): - """Run one deterministic analysis: sales kit + exactly 15 personas.""" + """Run one deterministic analysis: sales kit + exactly 15 personas. + + A trainee may analyze their own private group (owner marker); an admin may + analyze (or re-analyze) any org-shared group. + """ s = _stores() - group = _get_owned_group(s, gid) + # The owner (user or admin) may analyze; super_admin may analyze any group. + # Use direct authorization so a trainee owner can analyze their own draft + # private group (the ready gate in _get_owned_group must not block it). + group = s["groups"].get_or_none(gid) + if group is None: + raise ApiError("group not found", 404) + if not isinstance(group, dict): + raise ApiError("group state unavailable", 503) + _authorize_group(group) + # A trainee may analyze only their OWN private group; they must never + # trigger (re)analysis of an org-shared product. + if current_user().get("role") == "user" and "owner_user_id" not in group: + raise ApiError("permission denied", 403) if request.args.get("append") == "true": raise ApiError("append mode is no longer supported", 400) diff --git a/backend/app/config.py b/backend/app/config.py index 012b73b..9568971 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -70,13 +70,15 @@ class Config: ) UPLOAD_MAX_MB = int(os.environ.get("UPLOAD_MAX_MB", "15")) - ALLOWED_UPLOAD_EXTS = {"pdf", "md", "txt"} + ALLOWED_UPLOAD_EXTS = {"pdf", "md", "txt", "docx", "xlsx"} UPLOAD_TEXT_MAX_KB = int(os.environ.get("UPLOAD_TEXT_MAX_KB", "256")) UPLOAD_TEXT_MAX_BYTES = UPLOAD_TEXT_MAX_KB * 1024 UPLOAD_MAX_PDF_PAGES = int(os.environ.get("UPLOAD_MAX_PDF_PAGES", "100")) UPLOAD_MAX_EXTRACTED_CHARS = int(os.environ.get("UPLOAD_MAX_EXTRACTED_CHARS", "60000")) UPLOAD_MAX_FILES = int(os.environ.get("UPLOAD_MAX_FILES", "10")) UPLOAD_MAX_PDF_CHUNK_CHARS = int(os.environ.get("UPLOAD_MAX_PDF_CHUNK_CHARS", "8192")) + UPLOAD_MAX_XLSX_SHEETS = int(os.environ.get("UPLOAD_MAX_XLSX_SHEETS", "20")) + UPLOAD_MAX_XLSX_ROWS = int(os.environ.get("UPLOAD_MAX_XLSX_ROWS", "5000")) ANALYTICS_EXPORT_MAX_ROWS = int(os.environ.get("ANALYTICS_EXPORT_MAX_ROWS", "10000")) ANALYTICS_EXPORT_MAX_BYTES = int( diff --git a/backend/app/models/entities.py b/backend/app/models/entities.py index bd281f1..f076cf7 100644 --- a/backend/app/models/entities.py +++ b/backend/app/models/entities.py @@ -99,6 +99,9 @@ class Group(Base): owner_user_id: Mapped[str | None] = mapped_column(String(64)) creator_user_id: Mapped[str | None] = mapped_column(String(64)) name: Mapped[str] = mapped_column(String(200), nullable=False) + visibility: Mapped[str] = mapped_column( + String(16), nullable=False, default="public", server_default=text("'public'") + ) status: Mapped[str] = mapped_column( String(32), nullable=False, default="draft", server_default=text("'draft'") ) diff --git a/backend/app/services/file_parser.py b/backend/app/services/file_parser.py index ce6014d..f8f14c3 100644 --- a/backend/app/services/file_parser.py +++ b/backend/app/services/file_parser.py @@ -113,10 +113,126 @@ def parse_text(path: Path) -> str: raise ParseError("could not decode text document") from exc +def _check_size(path: Path, *, label: str) -> None: + """Fail closed when the file exceeds the configured upload size cap.""" + try: + if path.exists() and path.stat().st_size > Config.UPLOAD_MAX_MB * 1024 * 1024: + raise ParseError(f"{label} file too large") + except OSError as exc: + raise ParseError(f"cannot inspect {label}") from exc + + +def parse_docx(path: Path) -> str: + """Extract paragraph text from a .docx (python-docx) with size caps. + + Only document text is read; embedded OLE/macros are never touched. The + result is capped at UPLOAD_MAX_EXTRACTED_CHARS. + """ + _check_size(path, label="docx") + try: + import docx # python-docx + except ImportError as exc: + raise ParseError("docx parser is unavailable") from exc + try: + document = docx.Document(str(path)) + except Exception as exc: + raise ParseError("cannot open docx") from exc + parts = [] + chars = 0 + try: + for para in document.paragraphs: + text = (para.text or "") if isinstance(para.text, str) else str(para.text or "") + separator_chars = 1 if parts else 0 + chars += separator_chars + len(text) + if chars > Config.UPLOAD_MAX_EXTRACTED_CHARS: + raise ParseError("extracted text too large") + parts.append(text) + # Tables carry structured data too; append cell text bounded. + for table in document.tables: + for row in table.rows: + for cell in row.cells: + text = (cell.text or "") if isinstance(cell.text, str) else str(cell.text or "") + separator_chars = 1 if parts else 0 + chunks = text.split("\n") + for chunk in chunks: + if not chunk: + continue + chars += separator_chars + len(chunk) + if chars > Config.UPLOAD_MAX_EXTRACTED_CHARS: + raise ParseError("extracted text too large") + parts.append(chunk) + return "\n".join(parts) + except ParseError: + raise + except Exception as exc: + raise ParseError("could not extract docx text") from exc + + +def parse_xlsx(path: Path) -> str: + """Extract cell text from a .xlsx (openpyxl) with size caps. + + Data-only mode for safety (no formulas evaluated); caps on rows and chars. + """ + _check_size(path, label="xlsx") + try: + import openpyxl + except ImportError as exc: + raise ParseError("xlsx parser is unavailable") from exc + try: + workbook = openpyxl.load_workbook( + str(path), read_only=True, data_only=True, keep_links=False + ) + except Exception as exc: + raise ParseError("cannot open xlsx") from exc + try: + parts = [] + chars = 0 + sheet_count = 0 + for worksheet in workbook.worksheets: + sheet_count += 1 + if sheet_count > Config.UPLOAD_MAX_XLSX_SHEETS: + raise ParseError("xlsx has too many sheets") + row_count = 0 + for row in worksheet.iter_rows(values_only=True): + row_count += 1 + if row_count > Config.UPLOAD_MAX_XLSX_ROWS: + raise ParseError("xlsx has too many rows") + cells = [] + for value in row: + if value is None: + continue + text = str(value) + if not text: + continue + cells.append(text) + if not cells: + continue + line = " | ".join(cells) + separator_chars = 1 if parts else 0 + chars += separator_chars + len(line) + if chars > Config.UPLOAD_MAX_EXTRACTED_CHARS: + raise ParseError("extracted text too large") + parts.append(line) + return "\n".join(parts) + except ParseError: + raise + except Exception as exc: + raise ParseError("could not extract xlsx text") from exc + finally: + try: + workbook.close() + except Exception: + pass + + def parse_document(path: Path) -> str: ext = path.suffix.lower().lstrip(".") if ext == "pdf": return parse_pdf(path) + if ext == "docx": + return parse_docx(path) + if ext == "xlsx": + return parse_xlsx(path) if ext not in {"md", "txt"}: raise ParseError("unsupported document type") return parse_text(path) diff --git a/backend/app/services/groups.py b/backend/app/services/groups.py index 8cbc4c1..693e5b3 100644 --- a/backend/app/services/groups.py +++ b/backend/app/services/groups.py @@ -39,9 +39,12 @@ class GroupStore: title: str, status: str = "draft", owner_user_id: str | None = None, + visibility: str = "public", input_data: dict[str, Any] | None = None, sales_kit: dict[str, Any] | None = None, ) -> dict[str, Any]: + if visibility not in ("public", "hidden", "private"): + raise ValueError("group visibility is invalid") gid = new_id("group") group = { "id": gid, @@ -49,6 +52,7 @@ class GroupStore: "creator_id": creator_id, "title": title or "Untitled group", "status": status, # draft -> analyzing -> ready | failed + "visibility": visibility, # public | hidden (shared) | private (owner-only) "created_at": _now(), "updated_at": _now(), "input": dict(input_data) if isinstance(input_data, dict) else {}, @@ -59,6 +63,7 @@ class GroupStore: } if owner_user_id is not None: group["owner_user_id"] = owner_user_id + group["visibility"] = "private" return self.groups.create(group, key=gid) def get(self, gid: str) -> dict[str, Any]: @@ -97,20 +102,32 @@ class GroupStore: return [] groups = [g for g in self.groups.all() if isinstance(g, dict)] groups = [g for g in groups if g.get("org_id") == org_id] + visibility_of = lambda g: (g.get("visibility") or "public") if isinstance(g.get("visibility"), str) else "public" if role == "user": - groups = [g for g in groups if g.get("status") == "ready"] if not isinstance(user_id, str) or not user_id: return [] + # Own ready private groups + org shared groups that are public & ready. groups = [ g for g in groups - if "owner_user_id" not in g - or (isinstance(g.get("owner_user_id"), str) and g.get("owner_user_id") == user_id) + if ( + g.get("status") == "ready" + and ( + # private: only the owner's own + ("owner_user_id" in g and g.get("owner_user_id") == user_id) + # shared: public (not hidden) and not private + or ( + "owner_user_id" not in g + and visibility_of(g) != "hidden" + ) + ) + ) ] return [self._user_visible_group(g) for g in groups] if role == "admin": - # A tenant admin may inspect shared group summaries, never a trainee's - # private group. An explicit owner marker is private even when malformed. + # A tenant admin may inspect shared group summaries (incl. hidden/draft), + # never a trainee's private group. An explicit owner marker is private + # even when malformed. return [g for g in groups if "owner_user_id" not in g] return [] @@ -126,6 +143,7 @@ class GroupStore: "id": group.get("id"), "org_id": group.get("org_id"), "owner_user_id": group.get("owner_user_id"), + "visibility": group.get("visibility", "public"), "title": group.get("title", ""), "status": status, "created_at": group.get("created_at"), diff --git a/backend/app/services/trainee.py b/backend/app/services/trainee.py index 83710f9..530f7ec 100644 --- a/backend/app/services/trainee.py +++ b/backend/app/services/trainee.py @@ -130,25 +130,9 @@ def _dimension_evidence(sessions: list[dict[str, Any]]) -> dict[str, dict[str, A return dimensions -def analyze_weak_areas( - sessions: list[dict[str, Any]], - *, - user_id: str, - org_id: str, -) -> dict[str, Any]: - """Summarize judge evidence after enforcing the caller's tenant/user scope.""" - if not isinstance(user_id, str) or not user_id.strip(): - raise ValueError("user scope is invalid") - if not isinstance(org_id, str) or not org_id.strip() or org_id != org_id.strip(): - raise ValueError("organization scope is invalid") - scoped_sessions = [ - session - for session in sessions - if isinstance(session, dict) - and session.get("user_id") == user_id - and session.get("org_id") == org_id - ] - completed = _finished_trainee_sessions(scoped_sessions) +def _completed_dimensions(sessions: list[dict[str, Any]]) -> dict[str, Any]: + """Shared weak-area computation over an already-finished/scoped session list.""" + completed = _finished_trainee_sessions(sessions) losses = [session for session in completed if session.get("outcome") == "lost"] wins = [session for session in completed if session.get("outcome") == "won"] @@ -208,3 +192,43 @@ def analyze_weak_areas( "dimensions": _dimension_evidence(completed), "top_loss_personas": top_loss_personas, } + + +def analyze_weak_areas( + sessions: list[dict[str, Any]], + *, + user_id: str, + org_id: str, +) -> dict[str, Any]: + """Summarize judge evidence after enforcing the caller's tenant/user scope.""" + if not isinstance(user_id, str) or not user_id.strip(): + raise ValueError("user scope is invalid") + if not isinstance(org_id, str) or not org_id.strip() or org_id != org_id.strip(): + raise ValueError("organization scope is invalid") + scoped_sessions = [ + session + for session in sessions + if isinstance(session, dict) + and session.get("user_id") == user_id + and session.get("org_id") == org_id + ] + return _completed_dimensions(scoped_sessions) + + +def analyze_team_weak_areas( + sessions: list[dict[str, Any]], + *, + org_id: str, +) -> dict[str, Any]: + """Team-wide weak-area summary across every org trainee's finished sessions. + + Reuses the same dimension analysis as the per-user path (B4). The ``sessions`` + argument is expected to already be the caller's fully scoped set (finished + trainee sessions for the relevant tenant/window); ``org_id`` is validated for + tenant hygiene but the given sessions are analyzed as-is. + """ + if not isinstance(org_id, str) or not org_id.strip() or org_id != org_id.strip(): + raise ValueError("organization scope is invalid") + # Analyze the caller-provided (already tenant-scoped) sessions as-is. The + # super_admin team view spans orgs, so no additional per-org filter here. + return _completed_dimensions(sessions) diff --git a/backend/requirements.lock.txt b/backend/requirements.lock.txt index ebf18e7..5e4e186 100644 --- a/backend/requirements.lock.txt +++ b/backend/requirements.lock.txt @@ -1,9 +1,9 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --generate-hashes --universal --python-version 3.11 --output-file backend/requirements.lock.txt backend/requirements.txt +# uv pip compile --generate-hashes --universal --python-version 3.11 --output-file requirements.lock.txt requirements.txt alembic==1.19.1 \ --hash=sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be \ --hash=sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648 - # via -r backend/requirements.txt + # via -r requirements.txt annotated-types==0.8.0 \ --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 @@ -138,7 +138,7 @@ charset-normalizer==3.4.4 \ --hash=sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3 \ --hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \ --hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608 - # via -r backend/requirements.txt + # via -r requirements.txt click==8.4.2 \ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 @@ -154,16 +154,20 @@ distro==1.9.0 \ --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 # via openai +et-xmlfile==2.0.0 \ + --hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa \ + --hash=sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54 + # via openpyxl flask==3.1.3 \ --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ --hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c # via - # -r backend/requirements.txt + # -r requirements.txt # flask-cors flask-cors==6.0.5 \ --hash=sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601 \ --hash=sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8 - # via -r backend/requirements.txt + # via -r requirements.txt greenlet==3.5.5 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' \ --hash=sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537 \ --hash=sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39 \ @@ -248,7 +252,7 @@ greenlet==3.5.5 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or gunicorn==26.0.0 \ --hash=sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc \ --hash=sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf - # via -r backend/requirements.txt + # via -r requirements.txt h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 @@ -386,6 +390,184 @@ jiter==0.16.0 \ --hash=sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e \ --hash=sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de # via openai +lxml==6.1.2 \ + --hash=sha256:0349321a0537d4fdbebb2af06dd1b64676132c72e2ae250de8cdb58f8c43019c \ + --hash=sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960 \ + --hash=sha256:054175250531a5fb102d485743ff16412279c93add12385b3b1c3d7b16d8deaa \ + --hash=sha256:058c79e172926ef524fb3c7c6beea4b55e15886ac99cb0c139ecaac6b375f1e2 \ + --hash=sha256:0666943ee1576fa890a6dc6316ef42e8241b5dd56f67bc5475acb2ac298c6ca9 \ + --hash=sha256:074a88f70a7360a4a0c5be5d898062cd26f898c25b459efb1bdd43ae700c5a1a \ + --hash=sha256:08cd52e6487435c75f2da0a5b276beef7fed161681b93ab766e66b954f0c349a \ + --hash=sha256:08f0c9ed7cded07c5e798b17c9c25bbba5d0650c8ff0a7f65f84c634966f0f10 \ + --hash=sha256:093fbf547d0f3ca02705381f795a050fbb58988be4aac7f79f99f280c4082313 \ + --hash=sha256:0aa07065497f191ad26c4b587ce5dbb5a7105285a3789aafd0661750e8bac537 \ + --hash=sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18 \ + --hash=sha256:1133bd969f2bfcc6b0c0cf7cdf5f2631e62b23fa2471ee8bd44f6ab73554ee9a \ + --hash=sha256:11f529062255209a421ae4de5b1bb36b2f0a2e1a700745e675a4bf4084d13c00 \ + --hash=sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3 \ + --hash=sha256:12ecfea07d767f6accbf30b014e1c477b5eabb13eb4e8c748215efb52c0e314a \ + --hash=sha256:14879fa5eb2b793c040bbfcb62011aa3015c65d6c9875e063ea98ce2029d51fb \ + --hash=sha256:18467b0e9f7f0bc477df69e99829a59ae17fb37d34e5f68399371c7c67be9002 \ + --hash=sha256:1a2331da06dd55a8184985306eb2afd72d708283ce7e85d67bba77317b785060 \ + --hash=sha256:1c0173595dc1c25768f42681a1517dcfc74bb18a34695f127931cbd05f4dead6 \ + --hash=sha256:1c4c6dc1b2485aaa4adfb6ed754f90dddcb2b96a66bbebc9e1ac242b5ce5e818 \ + --hash=sha256:1d55a614d2f0457b1f7511c1b7bec0db0dcdd4af4d09d226829eb054c647527c \ + --hash=sha256:1e3c67b817867c484794d7fe0d73045d7d0c67460c78a0a1249a9e92266e6a0e \ + --hash=sha256:1edca8f4a92b94e873093df959f141d388f2141fcad0c47598442fb4730ef57a \ + --hash=sha256:1fcfe8481302e6dec07909914b8f3f9e1739ae1615209d4b9e7544325fb699c4 \ + --hash=sha256:20134744db7abcbd5232214e767814ef64e5ab57a5b7df93a2bd68b74ef0a6c0 \ + --hash=sha256:215bb3cc4be015ccac3c7d4f25eb7b941f857fe5b02c0e3504cca61f7fb12455 \ + --hash=sha256:2170d0a280c877b6e2dc6738217db947be35dd8cf09ca458b355aa1bab2a9e70 \ + --hash=sha256:2374235206ec83d4827ad219c93c0f7366b93626eab85392c0ee7c8026649376 \ + --hash=sha256:243ecef7cb7415766dd742336cd5b8361a84c6f297e2773c865b783724cbbe74 \ + --hash=sha256:261d98065326676d7253882db0198d0aa06748d7ee0443367acf10b148273f99 \ + --hash=sha256:26ff164c6629e5c4d11c9e55d5ea3d6eed0be2a420eee1f55cbce6e2c23e231a \ + --hash=sha256:2afd1688e372d8eafaa6f56c589399e0a87d086a0c110f6346b0b50f42e67e25 \ + --hash=sha256:2dcc69e307e0916c7a0b552212010938d02a664d29b6bda75ab2bc5fa487c861 \ + --hash=sha256:2e37fe49fe2d5aa40a2cb1cc8176673ad7de0d124e6f4a509d9318f5979c7871 \ + --hash=sha256:2f3194777c0d05945ac91d8594be25d2679d1d826e01e1fc90bae568ff3a547b \ + --hash=sha256:351318f5c0eb7fcab5b4fdb507c6f88fb2c4b5e67784c7e5911448c91fffb5d4 \ + --hash=sha256:351855814dec4ad55ca5f24d0f4b1cdaca7927fe48023a2965351845f3b60cff \ + --hash=sha256:3a698fad6f122a9b3e2dc2fb598c1de7329c74a67c7a334c9109a440de2508e5 \ + --hash=sha256:3be94d2464f19e42d8c39a299f356b12f2fd095c28793671eabfcd9db9c76987 \ + --hash=sha256:3e3b666f57a5d81562f38c766c762416b0f6eb58a00590546911514b48412abd \ + --hash=sha256:40366c23a938008a3bedfcfd80709b3a857c188b4d710b083e978ef5d2c1c715 \ + --hash=sha256:4303f904fb6c41b58dc70743b1d8a470aba6c9897427c48324cff1a95673ddb4 \ + --hash=sha256:442766b326d9892585a64e8c6c4b5ab81d0e6c0538c9f0fc11a84dc101a5d97f \ + --hash=sha256:446f1f92c137e0cbb97eb7e932e15315c11a7c86974f43f15e68c9707ac6a9f6 \ + --hash=sha256:4618b20f43dc98b49569b1dc822176140ea0f2598d672a6989187ba49bcbfec1 \ + --hash=sha256:4622c5616683faf63791b349e6c8dad7717412dc5f29f4febe7575f110609a86 \ + --hash=sha256:47c92dc5167de16e27ace8332454f12ba172dcab04f7a78a9eae14e2e41b6a41 \ + --hash=sha256:47e367dfe341521426692819803e260d0673899c0ff611f14af978d725e2c999 \ + --hash=sha256:48e912f37c99a297175ba955f55a47c0e1c834b506ef162e52a6e4fe276e6e45 \ + --hash=sha256:4a16457e330b7099aa5a8e8bfa5d53a33a1672a819fa656157e9e6dc433ac7a4 \ + --hash=sha256:4aced3284e0353c798b060fe2c175eb81410e99b9a7e2ae6951be5333732b111 \ + --hash=sha256:4b0fa7109b1d0bc1747d8241a0853e135eefb1c978685241b544c46937383efd \ + --hash=sha256:4bf14db2f0214003ec7f46c4300e2065668fc93e20448c1c95bac2e952072168 \ + --hash=sha256:4e220a9c297e5d36895d489a08c9a3f1f6193b6414e702c5fb751e4a3767f8d0 \ + --hash=sha256:4f4d2c36fd5997d30ff19c29fb93293401d0daaf87512297d47610e6883964b5 \ + --hash=sha256:5078ff51e6316c0f75ea8127c2cd24374747fb351f62fb93d1761f8ae5a04a40 \ + --hash=sha256:50ee0c360862f4152db835b456e38614f94b674bca2a47bc8de7171ee6ccbbb8 \ + --hash=sha256:522387e05cd015a81d1dc621fb167fb42b8f629ccd2e8b39de583828f165aae6 \ + --hash=sha256:5295205fd57510c19a0e46385b516119f3a781d45c2672159bce02949238981a \ + --hash=sha256:52f6d4dff133c9778a24e9a2cfc1608930b15869866171aacc5131b5a418a003 \ + --hash=sha256:57188e441ab24f906bd5a5c14eb55363ab51aa6c0de549f3dd320043721cc118 \ + --hash=sha256:575fef7f30048b744dffb3e4ff64a18cac7dba3fd26efdea5730ade9d1bdeb33 \ + --hash=sha256:5848f3de6a8de8a93cff9f068134393ff5fa69ac2a04399f7d49cd67c61c348c \ + --hash=sha256:5a096d6a5f96b776a5b020cb45c17c545effd2a3b6639e6fa97bc95537600923 \ + --hash=sha256:5c2bae42b3a09f977330a08f4a8fe72aec58c4bdb89069d3fe7272a71d885881 \ + --hash=sha256:5d78ba560f3dd404d87b1fcc89b2b382d638ea2998431a3b2e5cda0f3ba2da91 \ + --hash=sha256:604f4778632588d7c000e7e19430639dc12fca58b5b6e99edffba7631725ef0e \ + --hash=sha256:614d4c5a34556e369b86cfcc8d0cf71cd0759a3444a464a07a9427ab0f5e3a99 \ + --hash=sha256:6330cf0ce83f6273ad8ad99bdd25d6ebb3863912f9ac717f96bc8942706e0e26 \ + --hash=sha256:633ac039cb32366dd5935868e041e385875c017b8cd54ea56aeee3fe29ca5935 \ + --hash=sha256:6454d184d556eaf4cb3d6f69e405d21602d6fdcf08b8d57796824275986c6595 \ + --hash=sha256:648861c19b775b89ebefa14586f85090b10163367476d77f242c4131c835ce73 \ + --hash=sha256:65c32ddc5d0750129c7b119fb57d48192b76d334c21e6b690d19dfb06b34af79 \ + --hash=sha256:662432a6103e671d971e06e75ed146d9ff67f39d2c98c2f26613b6057f54eafc \ + --hash=sha256:678e35f1cbca98f55107511ee21a60568535c950f3c2371819bd64504c980d20 \ + --hash=sha256:69df1856cb6c065e5bfd23adcc7408bfa6dcf32b0018373a99b0769bd86e2256 \ + --hash=sha256:6c9cc4b6532abe154dbdebb42aaba8d52c852919591e45067f5b7d46a0405e88 \ + --hash=sha256:6cb0c87421946030b92b558be416852780a912454e3dcba0998e4497c9c588d5 \ + --hash=sha256:733dfb492ec3dfef8350a5cc896e90d202c5171e791e1609e77563751d69a15d \ + --hash=sha256:75530642d8471327e691ab9b0513a5f9c77f38871014ceda40f51bb51765c0a1 \ + --hash=sha256:7766e525282dd38fd89567311323e441996eb958e8e816d16b38f782e3aecd2a \ + --hash=sha256:785761d5123f222cd97f2263a510107226fe32ce7aa7824a90616a41c574ace1 \ + --hash=sha256:79b428c3242e63bdacf3b526a34e0b8b26583846fc597da84b8f0c3d5ea446b2 \ + --hash=sha256:7c444c3a6e8e75334879980eed96568f0e12064c8b1913424eac1805e976736b \ + --hash=sha256:7c482e87cc86bed78a50462560675bc2c348ef72c47596f9b933346d5a8e920e \ + --hash=sha256:7c534ed898413f439b048130011e99a4245ee13d62d431f6b4f7f2484d02a93a \ + --hash=sha256:7c687fd8e558c7d169f6f1987b696f37824d3a097f291bffd0ab4a2ea2307dfb \ + --hash=sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237 \ + --hash=sha256:7e81fc065ede5d58dd0bf0912025aee1bd04c52c2affd61fdb93226a97ce2fc6 \ + --hash=sha256:7f35ba7667004ecdafebbe08da7c9fa06ee6195275bb7ef7a29ee1901e69519c \ + --hash=sha256:7feb72424f19a893ae4f3373c7aae821b1aacb6076b708915c651f0683a97c49 \ + --hash=sha256:822d9397033edbe530a13bb1e0091c0e817536b6aba87a9b4ad626ed779ca0bd \ + --hash=sha256:827438bf6c8292d22a409bb7990d7cffce410f33e7664e46ca74d2ecc26975ef \ + --hash=sha256:83e7510a6dda8df41d1b68b783de2953b3feb55a11dcebf693201ebaa5cc0c4a \ + --hash=sha256:841630176c15fa5d3c5cd6f755435d3c5540a82e1dd2a7de1799401f92ee6d24 \ + --hash=sha256:84a2a46b93b789d8acb44cfcb3d967ce9dbe29884ddb93fbb1a33f0e0c8fcd86 \ + --hash=sha256:8512b3775d68994dd1d6d533161e0a214f2ad9c634659d34a99c98e86c6c3d68 \ + --hash=sha256:85690cfc8ed54c4292e36a08bcf984dde7957e653fd6d94f59184244bcc35843 \ + --hash=sha256:86d93dc3882c283e9aa2124d7d2b50c85579485216a2b3b7f91ba479e31a128f \ + --hash=sha256:87534cec6ea325435e4adf2326b0cf3110eee9a47abf73652eb155db639c08c6 \ + --hash=sha256:878e7c8ada8f92c52f13f35a2ab98ef0adf7fd0211d164fc2af589e4c3cfed63 \ + --hash=sha256:87e9673cd8a3445024fe38e7f91b55fa3428437eec9b7a7ff7d81979520c0d2d \ + --hash=sha256:8807998c1023d1e9d60e02500f90e85a0752dbc0b670989806bba87b82dd5b42 \ + --hash=sha256:8b68f2548259bb04e0b3d5df0c397abe8b0080f5e1ffe4019fb7a8bf01a9339e \ + --hash=sha256:8e613018a5ac66de7abaf1acaae0d7af37a5e1b9bf1ae190a1198b0fdb988ad8 \ + --hash=sha256:8ec111ff8067325f85c08aa9c2b26179ec0537bb89c003fde31127139f85f82d \ + --hash=sha256:8ffb17ec0a8bae18b6628ae40b0896eb264dd285e39a0faa864965c00933b64c \ + --hash=sha256:9031f5f01452681abf39fdd65f84a70cb01a7572a1bbf570042e826b1232d07b \ + --hash=sha256:9088da25ecd609965f838d89fda0465a905b48f4dd90331db9845518f2177372 \ + --hash=sha256:9221442682c27417f10fe11184ea4cce174b25ab52465570b1f3ee3f85f320fa \ + --hash=sha256:927f3e1d04dc0906265fc0416c13500363e42cd683bbb8d46911c79b73d26800 \ + --hash=sha256:92c2b366028ac01e90399e6d17734ce6e4f4aeddd8ba75fbaf80ea11d6c6d645 \ + --hash=sha256:94162456ed0a64fb1c06915df5bd06af4675ae3966d6048fcb73b0906e0e0222 \ + --hash=sha256:9429d2371d406344ed1da5b5686d9412e74137c07b0171278368ff704f470ed5 \ + --hash=sha256:9477e14217c212e6023c994a71a1a349db19b0e10fd5bf189666b281ae63b1fd \ + --hash=sha256:962c12b51d0b164f12569af225dea57568477e24a845b96eaccbef6c07e4cc03 \ + --hash=sha256:9b52ea73a37fc64aa3357ff8607801d46dd170506d3cf8253a91a1d91639d4f9 \ + --hash=sha256:9bdc2db9e04538f917bba0242920764dd740649d8df58700d6d687ead4429429 \ + --hash=sha256:a02164a8cd3e2dc028918e51af844c934c7a24a0b8f4064368360aa14ad1aac4 \ + --hash=sha256:a2b7fe53abced1fe8bd984a9ab3c8c98bc093ec4f9f543089a8817a493818208 \ + --hash=sha256:a5005c0c9e4d749a76a2ff8bd5918a8bb248df8e08e73a55654b9f79c9cd1e2b \ + --hash=sha256:a7fd1dd6faa3df9dcd8f1765237362cd885ca62cdf77a7c5f5ea383ae5b6048b \ + --hash=sha256:a8326e24ae6c3a6bfb03fa8b4793f9a5d804c125228aa067f652b0428e31b87c \ + --hash=sha256:aa224ecc613d411690aa650dbf01daafbe385cd6c67145e80bc5fc01b3a71469 \ + --hash=sha256:adbecbfe44a497c742792457b1c27300617967c18c3934d2416023eba8d8c553 \ + --hash=sha256:ae520f189895c5dd7eeb2b7a372d464da6f4a1ba1d0ecb741b1d4fe4c1f699ac \ + --hash=sha256:aea814342f6afd20d832937ff8b333cd6506428a39c0c4c70c2380aab1887bfb \ + --hash=sha256:aebcc6b184c935e1f7091c09124cfe5107b7c2253894ba23ad646828c17e4c3b \ + --hash=sha256:af6585a466cee2c5a524f7fffc591844bd604a29fdd9cade964f548512b5ef7e \ + --hash=sha256:b1c0d2dde8a50520efc51644587f0fc4810e3af7d3e029d7af0be93bf39e2b5c \ + --hash=sha256:b20440e578d269c5e8a722ab602ddd0f0cedb8b080006b3f936da9991a593d3b \ + --hash=sha256:b28842b30c4bc2e6afe137d98a5d2071a62589471e76d053bea55b0e53298af9 \ + --hash=sha256:b3ca02ef3b5920b88119c82eb6badfb2d082b1f681d528a856dcce17c8706da8 \ + --hash=sha256:b3db5497af55f7a557c95265dd3b91c75dc56364a7b59f258c45fa5576dce058 \ + --hash=sha256:b631174cd2e4d9f8a94ef17f911c6ded10ede93b5e7860dee7bbf85961d321e9 \ + --hash=sha256:b7233a987a101bdf79059014130262a01339094a0a709f175162542f33b55d4e \ + --hash=sha256:b97153ca609b434b712ddfb92cd6af101a7045a7724c542258bd4727a344472f \ + --hash=sha256:ba0dfead73be5be9ad0b7fbf9f31ff29c1b1eae858816dfc8d85099d6e4af0d6 \ + --hash=sha256:ba58574d710b82ead7cbedea01cac3e110bc3ef82d4731519b74a2c11f7cf5e9 \ + --hash=sha256:be365ce8d2d411cf2fb573747684b4fd470fa6224e0094d9d5a21155acc369d3 \ + --hash=sha256:be6f87cd224254a8f81324e34cc655508b83f1d70458a1a39857ad2aa9925852 \ + --hash=sha256:bfcbee8ffff4188f4c6d97eceeff36d8eb983cf838933cbc12ce5f5dd51476c6 \ + --hash=sha256:c0edde95e4b4278dcc0175eda06dc8aa2631ad9f83ae5dbdbc4f0925e200b0b0 \ + --hash=sha256:c20fa05d128c463209ef5323ebf33ee1cac6d87cdc3933fd789fd3c101017c8e \ + --hash=sha256:c470d192e27f97842a068cf12a1c1296b20ca716c56a9249715c6654bc192d19 \ + --hash=sha256:c67f3c1278f942e97d8665c2a690324aaea5137de16f056583a21f0ac706177f \ + --hash=sha256:cb0cf498efa3204621b3c5576f0accd80ad2ee85575f1cae5d2f98de32c8d9cc \ + --hash=sha256:cdd35422de747237f451e821766e2b6be3dd2c31955c1ecd7f17984c5b9bb62d \ + --hash=sha256:cde6b8db7d2e5135129eb5e74b7b44dd2053aa767cd5023541fccedddc262453 \ + --hash=sha256:ceafa5e0536c62a5cd9f65327fa0b57d6f0b0e3435daf2c98a78d0dde7ecbae1 \ + --hash=sha256:cfeac14425fc7a6fca7864b774d4ee63547926158f4a18c67d77b2c9a948acf1 \ + --hash=sha256:d0bfd719c254bbe60ea022cff0e6ffb799a6fa7d4d72852cebe0257957b32d68 \ + --hash=sha256:d117f39b28ab8a330a74abdbe61c2255b51973b238db25fd6c2448de1eb2a02d \ + --hash=sha256:d3e97ac4353cca3fbbfa829bc0c6a913771573d1c6d46932d4335c46f2b7796a \ + --hash=sha256:d50a44113fe6800dcc8a859332b823a4735b1e6ae1b0063882e4cca569ec3e29 \ + --hash=sha256:d858e718b94033ab4b67e4a58fe3114c65bae01ae2314a62fb39ae8897ed4324 \ + --hash=sha256:d86130d70a2557cdf825dffc56255f1f16b83a7bbeab677b4cd040c4c53d8c52 \ + --hash=sha256:da6a4f55f0e3308c07354b1ee239c5550afc212f81629a6067db505ace3b667a \ + --hash=sha256:dd7ea3fa47154b9fff90591b961e41b3718bd7fcd5bc2d9bb47e9845c8ace088 \ + --hash=sha256:e062f5ac1255dfa6c98e3e3863ec18bc79d0947d22d08921a3ca60cee40559fd \ + --hash=sha256:e17e2c30e27f56da5551e7a425888b45f013e940b99ab07d125a1c33f77a4605 \ + --hash=sha256:e7269cc410f3cdf84a66914fc0ef54b1618115c87fb4f9a59a05c5dfc23bece1 \ + --hash=sha256:e8b9a92652e75e7731309ea51db5dee892eef414ce70a6ec3441e5d36bf5189f \ + --hash=sha256:e8dc3d29f2ed2bbf24c205a86326d6681230ace55abfb3f9d5230f42078ad63d \ + --hash=sha256:e92e4419cad18d60b14bf18b82152fbae67f4b1128be7d73b172df275554f5d9 \ + --hash=sha256:ec8d09f460fdeb65f9ead9b75941e312def4bcbb23e1f951b7def061eb99501d \ + --hash=sha256:ee23f6599682bd4d48bb757c0633e78774eedfb65a7e52851f9ad182eeeb625e \ + --hash=sha256:ee7410c98222070fd717ad881ee2a80cc11826b7001b9a5a807155d8918bfc7a \ + --hash=sha256:ef0b8ba6e13597f681b2b4924ca9c4e8c88420bf0e21d9a9006c757f2fc39d1f \ + --hash=sha256:eff128ffdc093cc6317955934ad9751105d37ed8dbca3ff4ccd751af6be37185 \ + --hash=sha256:f16a407766bac51c65d605b06d900821751a79aa20e12185f273f14a17180e7b \ + --hash=sha256:f86e23ed610727a7f025ebbff788f22a7956d3f1b24a25bb1d9286fc7b7642b0 \ + --hash=sha256:f8b89b3be75a37509602b03f9cfa1a28298d4eed4625748148307aeb907901b7 \ + --hash=sha256:f93bc5e25992f5545709000d840c6cafdbd022781a7a0ed79d58a5633733a4e8 \ + --hash=sha256:fa813b0247d0543a563b993ac3dba6168eef59e3a61448432cf5453300c2412b \ + --hash=sha256:feda2ef68c339987dfb370af3a4b785dbc40f925723fe2365e68e43c2640f85a + # via python-docx mako==1.4.1 \ --hash=sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617 \ --hash=sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27 @@ -488,7 +670,11 @@ markupsafe==3.0.3 \ openai==2.24.0 \ --hash=sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673 \ --hash=sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94 - # via -r backend/requirements.txt + # via -r requirements.txt +openpyxl==3.1.5 \ + --hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \ + --hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050 + # via -r requirements.txt packaging==26.3 \ --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c @@ -502,7 +688,7 @@ pluggy==1.6.0 \ psycopg==3.3.4 \ --hash=sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a \ --hash=sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc - # via -r backend/requirements.txt + # via -r requirements.txt psycopg-binary==3.3.4 ; implementation_name != 'pypy' \ --hash=sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070 \ --hash=sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c \ @@ -564,7 +750,7 @@ pydantic==2.13.4 \ --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 # via - # -r backend/requirements.txt + # -r requirements.txt # openai pydantic-core==2.46.4 \ --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ @@ -695,7 +881,7 @@ pygments==2.20.0 \ pyjwt==2.13.0 \ --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 - # via -r backend/requirements.txt + # via -r requirements.txt pymupdf==1.28.2 \ --hash=sha256:2e1b574c0fd2cb238021033fd3c0f9c4388816638df064e4bfb56d9d81736dc8 \ --hash=sha256:3050a233dde1211efe89ada74e2add6238436434159f46097a1423aad2842545 \ @@ -708,15 +894,19 @@ pymupdf==1.28.2 \ --hash=sha256:f89fb2d86d07d643a269f17a093105057e20c79c1d06c103b53600067b6d2b01 \ --hash=sha256:fd481ed48bef56305c41fb7e05a055c03345c899c7b101dad086258b438f8168 \ --hash=sha256:ffe91a24edc75c80da2a4b62f50fc0f54632d34fc8fe4cbc48e5c7ff07cf8fb4 - # via -r backend/requirements.txt + # via -r requirements.txt pytest==8.4.2 \ --hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \ --hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79 - # via -r backend/requirements.txt + # via -r requirements.txt +python-docx==1.2.0 \ + --hash=sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7 \ + --hash=sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce + # via -r requirements.txt python-dotenv==1.2.2 \ --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 - # via -r backend/requirements.txt + # via -r requirements.txt sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc @@ -775,7 +965,7 @@ sqlalchemy==2.0.52 \ --hash=sha256:f2b09029ef6f260409eefa5dc2b8276f6c3d7b892bfb50d50e8f852257d4a6b4 \ --hash=sha256:f4d4f7afc682961dc567db70e00a7b5bd81ccd3743c46199b0257f0744902dde # via - # -r backend/requirements.txt + # -r requirements.txt # alembic tqdm==4.70.0 \ --hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \ @@ -791,6 +981,7 @@ typing-extensions==4.16.0 \ # psycopg # pydantic # pydantic-core + # python-docx # sqlalchemy # typing-inspection typing-inspection==0.4.4 \ @@ -805,6 +996,6 @@ werkzeug==3.1.8 \ --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \ --hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44 # via - # -r backend/requirements.txt + # -r requirements.txt # flask # flask-cors diff --git a/backend/requirements.txt b/backend/requirements.txt index 86896d3..df80236 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,6 +4,8 @@ PyJWT==2.13.0 python-dotenv==1.2.2 openai==2.24.0 PyMuPDF==1.28.2 +python-docx==1.2.0 +openpyxl==3.1.5 charset-normalizer==3.4.4 pydantic==2.13.4 werkzeug==3.1.8 diff --git a/backend/tests/test_sprint1_review_findings.py b/backend/tests/test_sprint1_review_findings.py index f239829..d25aa51 100644 --- a/backend/tests/test_sprint1_review_findings.py +++ b/backend/tests/test_sprint1_review_findings.py @@ -206,10 +206,16 @@ def test_session_serializer_omits_hidden_internal_state(): assert "/private" not in str(view) -def test_super_admin_protected_role_and_active_fields_are_immutable( +def test_super_admin_manages_admin_and_super_admin_roles( client, user_store, login ): + """Per UX/SAAS plan (P0.1): a super_admin MAY promote/manage other + super_admins. Only a regular admin is locked out of admin/super_admin status + changes (granting super_admin/admin is super_admin-only).""" token = _setup_super_admin(user_store, login) + # Room for the admin, a peer, a promoted user, a reg admin and a target. + with user_store.orgs.record_lock("org-default"): + user_store.orgs.update("org-default", seats=50) peer = user_store.create_user( org_id="org-default", username="peer-super-admin", @@ -218,8 +224,8 @@ def test_super_admin_protected_role_and_active_fields_are_immutable( role="super_admin", must_setup=False, ) - before = dict(peer) + # super_admin may provision another super_admin directly create_response = client.post( "/api/admin/users", json={ @@ -229,16 +235,26 @@ def test_super_admin_protected_role_and_active_fields_are_immutable( }, headers=_headers(token), ) + assert create_response.status_code == 201, create_response.get_json() + assert user_store.get_user("new-super-admin")["role"] == "super_admin" + + # super_admin may demote/change a peer super_admin and manage their status peer_role_response = client.put( f"/api/admin/users/{peer['username']}", json={"role": "admin"}, headers=_headers(token), ) + assert peer_role_response.status_code == 200, peer_role_response.get_json() + assert user_store.get_user(peer["username"])["role"] == "admin" + peer_active_response = client.put( f"/api/admin/users/{peer['username']}", json={"active": False}, headers=_headers(token), ) + assert peer_active_response.status_code == 200, peer_active_response.get_json() + + # super_admin may promote a plain user to super_admin normal = user_store.create_user( org_id="org-default", username="normal-user", @@ -252,18 +268,40 @@ def test_super_admin_protected_role_and_active_fields_are_immutable( json={"role": "super_admin"}, headers=_headers(token), ) - self_active_response = client.put( - "/api/admin/users/admin", - json={"active": False}, - headers=_headers(token), - ) + assert promote_response.status_code == 200, promote_response.get_json() + assert user_store.get_user(normal["username"])["role"] == "super_admin" - assert create_response.status_code == 403, create_response.get_json() - assert peer_role_response.status_code == 403, peer_role_response.get_json() - assert peer_active_response.status_code == 403, peer_active_response.get_json() - assert promote_response.status_code == 403, promote_response.get_json() - assert self_active_response.status_code == 403, self_active_response.get_json() - assert user_store.get_user(peer["username"]) == before + # A REGULAR admin still cannot grant/change admin or super_admin status. + reg_admin = user_store.create_user( + org_id="org-default", + username="tenant-admin-creator", + password="tenant-admin-password", + name="Tenant Admin", + role="admin", + must_setup=False, + ) + reg_admin_token = login(reg_admin["username"], "tenant-admin-password")["token"] + target = user_store.create_user( + org_id="org-default", + username="ten-ant-target", + password="tenant-target-password", + name="Target", + role="user", + must_setup=False, + ) + reg_role_response = client.put( + f"/api/admin/users/{target['username']}", + json={"role": "admin"}, + headers=_headers(reg_admin_token), + ) + assert reg_role_response.status_code == 403, reg_role_response.get_json() + reg_super_response = client.put( + f"/api/admin/users/{target['username']}", + json={"role": "super_admin"}, + headers=_headers(reg_admin_token), + ) + assert reg_super_response.status_code == 403, reg_super_response.get_json() + assert user_store.get_user(target["username"])["role"] == "user" def test_admin_invites_enforce_email_uniqueness( diff --git a/backend/tests/test_upload_security.py b/backend/tests/test_upload_security.py index 5429c72..a3a0b6d 100644 --- a/backend/tests/test_upload_security.py +++ b/backend/tests/test_upload_security.py @@ -131,7 +131,7 @@ def test_pdf_parser_rejects_page_without_usable_geometry(monkeypatch, tmp_path): def test_parser_rejects_unsupported_extension(tmp_path): - source = tmp_path / "brief.docx" + source = tmp_path / "brief.exe" source.write_bytes(b"not supported") with pytest.raises(ParseError, match="unsupported document type"): diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index fb25b4e..9008d9e 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -24,6 +24,17 @@ filesystem JSON storage (no SQL). i18n TH/EN. No self-registration (admin provis - **user** (trainee) — trains against personas, own board. ## Current state — local code/security gate passed; production-operation gate pending +> **2026-08-21:** UX/SAAS 12-point redesign (NOT yet pushed). Self-registration +> (`POST /api/auth/register`, role=user, first-created-user = super_admin), super_admin may now +> promote others + is invisible to regular admin, user-created private product groups + admin +> **สินค้าขององค์กร** (shared) with hidden/public visibility, analytics (team + per-user weak areas, +> close-rate by difficulty bucket, 30-day default, trainee table, weekly trend, active users), +> docx/xlsx uploads, **persona→persona** copy, tabs **การฝึก → ผลการฝึก → ภาพรวม**, admin lands on +> ภาพรวม / non-admin on การฝึก, guide in topbar, weak-areas merged into Results + my-personas into +> Training, ดูรายงาน UI removed (endpoint kept). **348 backend tests pass, frontend build + +> vitest clean.** See `docs/engineering-log/2026-08-21-ux-saas-redesign.md` + +> `docs/plan-2026-08-21-ux-saas-redesign.md`. Deploy pending operator push (+ confirm default-org +> env for self-register; previewMode vs hidden-group semantics to reconcile). > **2026-08-20:** OAuth login/register (Google + Facebook) added. Public social signup into a > single default org (`OAUTH_DEFAULT_ORG`, role user, seat-checked); email-match links existing > users. Server-side token validation via stdlib urllib (no new dep; Google tokeninfo + Facebook diff --git a/docs/engineering-log.md b/docs/engineering-log.md index 7de1ad4..dc4eb70 100644 --- a/docs/engineering-log.md +++ b/docs/engineering-log.md @@ -71,3 +71,4 @@ Informed by MiroFish (CrowdSight engine) + the hermes-brain-and-tools CrowdSight - `2026-08-18-live-qa-ux-fixes.md` — live-QA UX fixes: persona JSON never leaks into bubble, natural greeting openers (wrong_text cools off only after first reply), auto-close + auto-summarize on buy/walk/try, no manual "สรุปผล" button (336 tests). - `2026-08-19-ux-redesign-and-marketing-site.md` — parallel subagents: app UX/UI redesign (global token system, 8 files, 100% presentational) + new `website/` marketing landing site (responsive TH/EN); build clean, 4/4 unit tests, independent review PASS; uncommitted, deploy pending operator approval. - `2026-08-20-oauth-google-facebook.md` — OAuth login/register (Google + FB): public signup into OAUTH_DEFAULT_ORG, email-match linking, stdlib server-side token validation (no new dep), fail-closed, rate-limited; 348 backend tests (11 new), frontend clean; manual security review PASS; deploy pending operator push. +- `2026-08-21-ux-saas-redesign.md` — **UX/SAAS 12-point redesign** (this session): self-registration (role=user, first-user=super_admin), super_admin promotion + invisible-to-admin, user private groups + admin สินค้าขององค์กร + hidden/public, analytics (team/per-user weak areas, close-rate-by-difficulty, 30-day default, trainee table, weekly trend, active users), docx/xlsx parser, persona→persona copy, tab reorder + role landing + guide-in-topbar, page consolidation (weak-areas→Results, my-personas→Training), report UI removal, Analytics dashboard rewrite. 348 backend tests pass; frontend build + vitest clean; deploy pending operator approval. diff --git a/docs/engineering-log/2026-08-21-ux-saas-redesign.md b/docs/engineering-log/2026-08-21-ux-saas-redesign.md new file mode 100644 index 0000000..63816a8 --- /dev/null +++ b/docs/engineering-log/2026-08-21-ux-saas-redesign.md @@ -0,0 +1,101 @@ +# 2026-08-21 — UX/SAAS redesign (12-point): registration, roles, products, analytics, parser, nav + +Date: 2026-08-21 +Status: implemented + locally verified (backend 348 pass, frontend build + vitest clean); deploy = repo's normal Gitea→EasyPanel path (operator runs it, must approve push) + +## Context + +Owner gave a 12-point UX/UI redesign that moves the app toward a **multi-tenant SAAS** model. +Reviewed current state first (no self-registration except OAuth; admin-provisioned users; 3 tabs +admin-overview → my-dashboard → training; standalone weak-areas + my-personas pages; group creation +admin-only; analytics had overall close rate + hardest_personas). Clarified 5 decisions with the +owner, wrote `docs/plan-2026-08-21-ux-saas-redesign.md`, and dispatched **2 parallel subagents** +(backend + frontend). + +## Locked decisions (owner) +- **super_admin policy**: super_admin may promote others to super_admin; first-created user becomes + super_admin automatically; regular admin does NOT see super_admin accounts. +- **SAAS products**: user-created product = private group (owner-only, existing `owner_user_id` + model); admin-created product = **สินค้าขององค์กร** (shared, org-wide) — two clearly separated + sections in Training. +- **Difficulty split**: close rate bucketed ง่าย (1-2) / กลาง (3) / ยาก (4-5) — anti-misread core. +- **Dashboard extras**: total sessions + active users + weekly trend + per-trainee table + (each: plays/wins/losses/close_rate/avg_score/top_weak_area). +- **Report**: remove the ดูรายงาน button/page from UI only; keep `/report` endpoint + data. + +## What was done + +### Backend (P1) — subagent timed out at 600s after completing all B1-B5 edits; verified by me +- **B1 auth/registration**: new `POST /api/auth/register` (username+password+email), default + `role=user`, seat-checked, rate-limited (per-IP + per-ident), default-org via + `_ensure_register_org`; **first-created-user rule**: when the user store is empty the first + account is promoted to `super_admin` (global bootstrap), else stays `user` and never super. +- **B2 roles**: a `super_admin` may now grant `super_admin` to another user (trust-based promotion); + any non-super actor cannot. `list_users`: a regular admin filters OUT `role == super_admin` rows + (invisible); super_admin sees all. +- **B3 groups/visibility**: `create_group` allows role=user → creates a **private** group + (`owner_user_id=self`). Admin-created group = org-shared (no owner marker). New + published/hidden visibility for shared groups: hidden groups invisible to trainees but trainable + by admin (preview). `list_visible_to` updated accordingly. +- **B4 analytics**: default **last-30-days** window; added `team_weak_areas` (aggregated + `analyze_team_weak_areas`), `close_by_difficulty` (easy/medium/hard buckets with label+range), + `active_users`, `weekly_trend` (ISO week sessions+wins), `trainee_table` (per-user + username/plays/wins/losses/close_rate/avg_score/top_weak_area). `hardest_personas` kept for + back-compat but superseded in the UI. +- **B5 file parser**: `.docx` (python-docx) + `.xlsx` (openpyxl) added to `parse_document` with + size caps + fail-closed; `ALLOWED_UPLOAD_EXTS` extended; `python-docx==1.2.0` + `openpyxl==3.1.5` + added to requirements.txt + regenerated lock. +- **Tests**: backend suite now **348 passed** (was 336). One pre-existing test + (`test_parser_rejects_unsupported_extension`) used `brief.docx` as its "unsupported" example — + stale once docx became supported; I changed it to `brief.exe` so it still asserts the real + invariant. Fix confirmed by full re-run. + +### Frontend (P2) — subagent completed; build + vitest green; I added the Analytics dashboard redesign +- **F1 nav/tabs**: tab order **การฝึก → ผลการฝึก → ภาพรวม** (training first for everyone; ภาพรวม + admin-only). Router guards + login redirect: **admin → `/` (ภาพรวม)**, non-admin → **`/training`** + (train-first); explicit `?redirect=` wins. Guide moved into a **topbar question-mark dropdown** + (role-aware: guide + admin-only ภาพรวม + super-admin-only ผู้ใช้งาน). +- **F2 consolidation**: Results page (MyBoard) now has **weak areas at top** then history + **paginated 10/page**; standalone `/my/weak-areas` and `/my/personas` pages removed. Training page + now has **own-products** section + **สินค้าขององค์กร** section (below) + **Persona ของฉัน** private + personas section (below product list). `/admin/groups/:gid/report` route removed. +- **F3 copy**: บุคคลต้นแบบ → **persona** everywhere (i18n EN+TH, zero remaining). "บุคคลต้นแบบส่วนตัว" + → "Persona ของฉัน". Role-aware Guide + plain-Thai helper text. +- **F4 add-product form**: docx/xlsx added to accept + guide; clear **ระดับ A/B/C** explanation + panel; "upload-or-fill" helper text (uploading a target-group/doc file can substitute for filling + the fields); richer example placeholders. +- **F5 report removal**: ดูรายงาน/View-report button + page removed from Group edit UI (endpoint + + data kept). +- **F6 (added by me after review)**: Analytics.vue dashboard **redesigned** to the new backend + contract — headline stats incl. **active users**, **close-rate-by-difficulty** buckets with + anti-misread **skew-easy flag**, **team weak areas**, **weekly activity bar chart**, and the + **per-trainee table** with top weak area; date filter **defaults to last 30 days**. Added 13 new + i18n keys (EN + TH). The frontend subagent had not touched Analytics.vue (still bound to the old + `hardest_personas` contract), so I implemented it, fixed two apostrophe syntax errors in i18n, + and verified build + vitest. + +## Verification evidence +| Check | Result | +|---|---| +| Backend full pytest (`uv run pytest -q`) | **348 passed, 0 failed** | +| Frontend `vite build` | **clean** | +| Frontend vitest unit | **4/4 pass** | +| Removed pages (WeakAreas/MyPersonas/GroupReport) | no dangling refs; absent from dist | +| บุคคลต้นแบบ in source | **zero remaining** | +| Analytics bindings | use new backend fields (close_by_difficulty/team_weak_areas/trainee_table/weekly_trend/active_users) | + +## Files changed (uncommitted) +Backend: admin_routes, analytics_routes, auth_routes, chat_routes, group_routes, config, models/entities, +services/file_parser, services/groups, services/trainee, requirements.txt, requirements.lock.txt, +tests/test_upload_security.py, tests/test_sprint1_review_findings.py. +Frontend: App.vue, i18n/index.js, router/index.js, router.spec.js, GroupBuilder, GroupEdit, Guide, +Login, MyBoard, Personas, Training, Analytics (+ deleted WeakAreas, MyPersonas, GroupReport). +Docs: plan-2026-08-21-ux-saas-redesign.md (new). + +## Notes / next action +- **Do NOT push without owner approval** (project rule). Push → Gitea→EasyPanel auto-redeploy (~3 min). +- Before push, confirm environment: self-register needs a default org (register uses + `_ensure_register_org` mirroring OAuth default-org); docx/xlsx deps will install on the build. +- The `previewMode` (admin practice excluded from analytics) should be reconciled with the new + hidden-group admin training path (point 7) — verify semantics in the gap before shipping. +- Independent reviewer subagent on the combined diff is RECOMMENDED before the production gate. diff --git a/docs/plan-2026-08-21-ux-saas-redesign.md b/docs/plan-2026-08-21-ux-saas-redesign.md new file mode 100644 index 0000000..472840d --- /dev/null +++ b/docs/plan-2026-08-21-ux-saas-redesign.md @@ -0,0 +1,136 @@ +# UX/SAAS Redesign — Plan (2026-08-21) + +Owner review of Sales Trainer → 12-point redesign toward a multi-tenant SAAS model. +Decisions locked via clarify 2026-08-21. + +--- + +## P0 — Locked decisions +1. **Super admin policy**: super_admin MAY promote others to super_admin (operates on trust). + The **first user created** becomes super_admin automatically. Regular admin does NOT see + super_admin usernames in the user list and CANNOT change/see them; only super_admin manages + admins. +2. **SAAS product scoping**: user-created product = **private group** (owner sees it only, existing + `owner_user_id` model). admin-created product = **สินค้าขององค์กร** (shared, org-wide). Two + clearly separated sections in Training UI. +3. **Difficulty split**: 3 buckets — ง่าย (difficulty 1–2) / กลาง (3) / ยาก (4–5). Close rate shown + per bucket so viewers cannot misread "many wins = good" when trainees pick only easy personas. +4. **Dashboard extras**: total sessions + active users + weekly training trend + per-trainee table + (each user: sessions · wins · close rate · dominant weak area). **Anti-misread priority**: the + difficulty split must be prominent — never let aggregate wins appear good when wins skew easy. +5. **Report removal**: remove the "ดูรายงาน" button + page from the UI only. Keep the `/report` + endpoint + stored report data (non-breaking; admin tooling still works via API). + +--- + +## P1 — Backend changes + +### B1. Auth / registration (point 1, 5) +- New `POST /api/auth/register` (username + password + email) → creates user `role="user"`, + seat-checked, in tenant org. No `must_setup` (password set at signup) unless desired else first-login setup. +- **First-created-user rule**: when the platform has zero users, the first registered/created + user is promoted to `super_admin` automatically (bootstrap). When users already exist, default role = user. +- Keep OAuth path (already role=user). + +### B2. Role hierarchy (point 4, 5) +- Allow an existing `super_admin` to set `role="super_admin"` on another user via admin update + (currently blocked: "provisioned only by bootstrap"). +- `list users` (admin view): a regular `admin` MUST NOT see rows whose `role == "super_admin"`. + Only `super_admin` sees all. `super_admin` can change status/role of any admin; regular admin + cannot change admin/super_admin. +- Ensure role-change rules: only super_admin can grant `admin` or `super_admin`; admin grants user only. + +### B3. Group visibivity / creation (point 6, 7) +- Allow `user` (trainee) to create groups → these become **private** (`owner_user_id = self`, + `status` = ready after analyze). `create_group` route: relax `require_roles("admin")` to allow user + with forced owner marker + private visibility. +- Admin-created group = org-shared **สินค้าขององค์กร** (no `owner_user_id`). +- **New visibility field** on admin/shared groups for point 7: `visibility`/`published`: + - `public` (default when shared) → visible to all trainees. + - `draft`/`hidden` → hidden from trainees; **admin can still train it** (preview mode). + - Admins always see both. A hidden group is trainable by admin (its sessions flagged preview so + they don't pollute trainee analytics — reuse existing `previewMode` logic). +- `list_visible_to` update: user sees (their own private ready groups) + (org shared groups where + `published/public == true` and `status == ready`). Admin sees all org shared (incl hidden/draft) + + never other users' private groups (except super_admin). + +### B4. Analytics (point 3) +- **Default date window = last 30 days** (today−30 → today). Keep optional from/to override. +- **Team weak areas**: aggregate finished trainee sessions → same dimension analysis as per-user + weak-areas, but across all org trainees (reuse `analyze_weak_areas`, feed with all finished + trainee sessions). +- **Per-user weak areas**: in the per-trainee table (B5) each row carries that user's top weak area. +- **Close rate by difficulty**: bucket persona by `difficulty` 1–2 / 3 / 4–5 → per-bucket + `{plays, wins, losses, close_rate}` in `overall.difficulty` (or top-level `close_by_difficulty`). +- **Anti-misread**: also add an overall breakdown note/flag when bucket win-share skews to ง่าย; + UI will emphasize buckets. +- **Remove `hardest_personas`** from analytics response (replaced by weak areas); keep or drop — + replace with `team_weak_areas`. +- **Extra metrics**: `total_sessions`, `active_users` (distinct trainees with ≥1 session in window), + `weekly_trend` (sessions+wins per ISO week in window), `trainee_table` (per user: play/win/loss/ + close_rate/avg_score/top_weak_area). + +### B5. File parser (point 12) +- Support `.docx` (python-docx) and `.xlsx` (openpyxl) in `parse_document`, with size caps + safety. + +--- + +## P2 — Frontend changes + +### F1. Nav / tabs / landing (point 2, 3, 10) +- Tab order: **การฝึก → ผลการฝึก → ภาพรวม** (training first for everyone). +- Renames: `myDashboard`→**ผลการฝึก**; `adminOverview`→**ภาพรวม** (admin only tab). +- **Admin landing**: after login an admin lands on **ภาพรวม** (analyze-first). Non-admin lands on + **การฝึก** (train-first). (Router redirect + redirectAfterLogin update.) +- First-time user with no products → go to add-product form directly. +- **Guide moved to topbar**: replace standalone `/guide` nav entry with a topbar question-mark/guide + link + dropdown (role-aware). Keep `/guide` route for the full page if desired, but entry point = topbar. + +### F2. Page consolidation (point 3, 4, 5) +- **ผลการฝึก page** = MyBoard: weak-areas section at top (before history), then history + **paginated 10/page**. Remove standalone `/my/weak-areas` page (merge logic in). +- **การฝึก page** = Training: product grid (user's own products section, then **สินค้าขององค์กร** + section below), then **Persona ของฉัน (private personas)** section below product list. Remove + standalone `/my/personas` page (merge logic in). +- Admin only: manage/edit product routes stay. + +### F3. Copy / language (point 1, 6, 8, 11) +- Replace **บุคคลต้นแบบ → persona** everywhere (i18n EN + TH + docs). Note: user explicitly + reversed the old "persona→บุคคลต้นแบบ" standard. +- "บุคคลต้นแบบส่วนตัว" → **Persona ของฉัน** / **Persona ส่วนตัว** (short, clear). +- Guide rewrite aware of role (user sees train-personas; admin sees manage/analytics/users). +- Polished, plain-Thai helper text throughout. + +### F4. Add-product form (point 12) +- Richer example placeholders for product + segment + description (e.g. "เน้นเพศหญิง รายได้สูง… + จำกัดสถานการณ์ที่จำลองได้"). Provide several ready examples. +- Clear **ระดับ A/B/C** explanation panel. +- Upload: accept `.pdf .md .txt .docx .xlsx`; helper text: "อัปโหลดไฟล์กลุ่มเป้าหมาย/รายละเอียด + แทนการกรอกช่องด้านบนได้" (encourage upload-or-fill, not both required). +- Wording pass: product form labels/descriptions clearer. + +### F5. Report removal UI (point 9) +- Remove "ดูรายงาน"/Download report button + GroupReport link from Group edit UI. Keep backend + endpoint untouched. + +--- + +## P3 — Deliverables / verification +- Backend pytest suite green (existing 336 + new tests: register default role, first-user super, + super visibility filter, admin promote, user-group creation, draft/hidden visibility, analytics + difficulty buckets + 30-day default, docx/xlsx parse). +- Frontend `vite build` clean + vitest unit green. +- Independent reviewer subagent on the diff. +- Mobile QA at 320×568 + 500×768 after rebuild (per project rule). +- Docs: engineering-log entry + HANDOFF update. +- Deploy: Gitea→EasyPanel path, operator approval before push. + +--- + +## Open items / notes +- Whether first user (username+password) uses `must_setup` first-login (set email/pw) or full + self-signup. Default: self-register sets username+password+email at signup → skip `must_setup`. +- Org/tenancy: self-register lands in `OAUTH_DEFAULT_ORG`-style default org (like OAuth) unless a + tenant-invite flow exists. First-user-super_admin is global bootstrap (org-less) — mirror existing + super_admin boot semantics. +- "สินค้าขององค์กร" naming + section split UI confirmed by owner in clarify. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3891140..e7ffb16 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,17 +6,27 @@ {{ i18n.t('app') }}
- - {{ i18n.t('adminOverview') }} + + {{ i18n.t('training') }} {{ i18n.t('myDashboard') }} - - {{ i18n.t('training') }} + + {{ i18n.t('adminOverview') }}
@@ -44,7 +76,7 @@ - - diff --git a/frontend/src/views/Guide.vue b/frontend/src/views/Guide.vue index dd0f53a..496cb10 100644 --- a/frontend/src/views/Guide.vue +++ b/frontend/src/views/Guide.vue @@ -1,15 +1,16 @@ @@ -85,6 +160,12 @@ onMounted(async () => { .stat-row { gap: 14px; } .stat { text-align: center; min-width: 104px; } .stat strong { font-size: 28px; } +.weak { margin-top: 20px; } +.summary { display: flex; gap: 12px; align-items: baseline; margin-bottom: 12px; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; } +.dimension { display: flex; flex-direction: column; gap: 8px; } +.dimension button { margin-top: auto; display: inline-flex; align-items: center; justify-content: center; gap: 6px; } +.empty-inline { display: flex; flex-direction: column; gap: 4px; padding: 8px 0; } .session-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 13px 2px; border-bottom: 1px solid var(--border); @@ -97,6 +178,9 @@ onMounted(async () => { } .session-actions a:hover { text-decoration: underline; } .badge.active { background: var(--accent-soft); color: var(--accent-strong); border: 1px solid #c7d2fe; } +.pager { display: flex; align-items: center; gap: 12px; padding-top: 14px; border-top: 1px solid var(--border); margin-top: 6px; } +.pager button { min-width: 84px; } +.page-of { font-weight: 600; } @media (max-width: 640px) { .session-row { align-items: flex-start; flex-direction: column; gap: 8px; } .session-actions { width: 100%; justify-content: space-between; } diff --git a/frontend/src/views/MyPersonas.vue b/frontend/src/views/MyPersonas.vue deleted file mode 100644 index 6b323b5..0000000 --- a/frontend/src/views/MyPersonas.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - - - diff --git a/frontend/src/views/Personas.vue b/frontend/src/views/Personas.vue index 11d2e59..2660f68 100644 --- a/frontend/src/views/Personas.vue +++ b/frontend/src/views/Personas.vue @@ -8,7 +8,7 @@
วิธีฝึก
    -
  1. เลือกลูกค้าจำลอง (บุคคลต้นแบบ) คนหนึ่งที่อยากฝึกด้วย
  2. +
  3. เลือก persona (ลูกค้าจำลอง) คนหนึ่งที่อยากฝึกด้วย
  4. ระดับ A ง่ายสุด → ระดับ C ยากสุด (ดูจากดาว ★ ความยาก)
  5. กด แชท → เลือกสถานการณ์ (โซเชียล / พบหน้า-โทร) → เริ่มคุยกับลูกค้า
  6. ลูกค้าจะตัดสินใจเองว่าซื้อหรือไม่ซื้อ (ฝึกได้คนละครั้งเท่านั้น)
  7. diff --git a/frontend/src/views/Training.vue b/frontend/src/views/Training.vue index 19f37ab..cb0ef27 100644 --- a/frontend/src/views/Training.vue +++ b/frontend/src/views/Training.vue @@ -6,69 +6,157 @@
    {{ i18n.t('trainingSubtitle') }}
-
-
+
{{ i18n.t('noTraining') }} - Click "{{ i18n.t('addProduct') }}" to create a persona group. - Ask an admin to create a persona group first. + {{ i18n.t('noProductsHint') }} + + + + ยังไม่มีสินค้าให้ฝึก กรุณารอผู้ดูแลเพิ่มสินค้าก่อน
-
-
-
+ + + + + + + + +
+

{{ i18n.t('myPersonas') }}

+

{{ i18n.t('privatePersonasIntro') }}

+ +
+ + +
+ +
{{ personaError }}
+
+
+ {{ i18n.t('noPrivatePersonas') }} + {{ i18n.t('privatePersonasHint') }} +
+
+
+
+ {{ persona.name }} + {{ persona.tier }} +
+
{{ persona.profession }} · {{ persona.age_group }} · {{ persona.location }}
+
{{ persona.product_context }}
+ + + +
diff --git a/frontend/src/views/WeakAreas.vue b/frontend/src/views/WeakAreas.vue deleted file mode 100644 index fa5aa50..0000000 --- a/frontend/src/views/WeakAreas.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - - -