shipped

Spec: F43 — Admin HQ (/hq) for Staff & Superadmin Users

Status: shipped

Contract Graph boundary (2026-07-14): F43.cue owns the 12 staff/superadmin HQ API methods and their backend authorization/state proofs. F85 now owns the pilot API boundary separately. The graph keeps React route/browser runtime, recent Sentry issues, and the F85 pilot dashboard runtime visibly open; API source presence is not evidence that the staff product surface is complete.

1. Problem & CEO Job

Job: See clearly — Lumi’s support, devops, and product operators lack a dedicated surface. The unskinned Django CRUD shell at /tmt/ cannot test email, trigger notifications, inspect cross-org metrics, or manage users without SSH into a production container.

Admin HQ (/hq) is the staff/superadmin operating dashboard within the React SPA, with separate navigation and permission tiers, invisible to non-staff users.

2. User Stories & Acceptance Criteria

Staff User (is_staff=True)

  • As a staff user, I want to access runlumi.app/hq, so that I can operate and monitor Lumi.

    • GET /hq returns the admin dashboard. Non-staff users are redirected to /work/today.
    • GET /hq/* routes all require is_staff=True in the JWT.
  • As a staff user, I want to see operational metrics at a glance, so that I know the system’s health.

    • Dashboard shows: total orgs, total users, active orgs (7-day), daily active users, API error rate, Celery queue length.
    • Dashboard shows recent Sentry issues (if Sentry integration is configured).
  • As a staff user, I want to test email delivery and notifications, so that I can verify they work before a customer complains.

    • /hq/notifications has a form: send a test email to any address, send a test push notification to any device.
    • Email form accepts user search (resolves to email) or a custom email address.
    • Push form uses debounced user search picker, sends real device push via FCM.
    • Form shows delivery status (sent/failed/no-active-devices) and timing.
  • As a staff user, I want to browse and search organizations, so that I can assist customers.

    • /hq/orgs lists all organizations with search, sort, and filter.
    • Clicking an org shows details: member count, subscription status, last pulse date, date created.

Superadmin User (is_superuser=True)

  • As a superadmin, I want to manage users across all organizations, so that I can handle account issues.

    • /hq/users lists all users with search, sort, and filter. Superadmin only.
    • Clicking a user shows: email, name, orgs they belong to, last login, is_staff flag, date joined.
    • Superadmin can toggle is_staff on any user (grant/revoke staff access).
    • Superadmin can temporarily impersonate a user (login as them) for debugging — audited.
  • As a superadmin, I want to view audit logs across all organizations, so that I can investigate security incidents.

    • /hq/audit shows a cross-org audit log with search, filter by org/action/actor, and date range.
  • As a superadmin, I want to view system configuration, so that I know what’s deployed.

    • /hq/system shows: Django version, Python version, LLM provider, Redis status, DB status, Celery worker status, last deploy timestamp (from version.json).
    • /hq/pilot (F85) shows the pilot activation gates (update-completion / retention / action-rate / expansion / payment / accuracy) and the per-firm cohort table. Staff-only, same is_staff guard as every other /hq/* route; reads GET /api/hq/pilot/gates/ and GET /api/hq/pilot/cohort/. The API boundary is now contract-owned by F85; the browser/runtime criterion remains open.

3. Scope Boundaries

  • In:
    • /hq route group in the React SPA, protected by is_staff check.
    • Separate admin sidebar with HQ-only navigation items.
    • Backend API endpoints for HQ data (all require is_staff=True):
      • GET /api/hq/dashboard/ — metrics
      • POST /api/hq/notifications/test-email/
      • POST /api/hq/notifications/test-push/
      • GET /api/hq/orgs/ — list organizations
      • GET /api/hq/orgs/{id}/ — org detail
      • GET /api/hq/users/ — superadmin only
      • GET /api/hq/users/{id}/ — superadmin only
      • PATCH /api/hq/users/{id}/ — superadmin only (toggle is_staff)
      • POST /api/hq/users/{id}/impersonate/ — superadmin only
      • GET /api/hq/audit/ — superadmin only
      • GET /api/hq/system/ — superadmin only
    • JWT payload includes is_staff and is_superuser claims.
    • useAuthStore decodes and stores isStaff and isSuperuser from JWT.
  • Out:
    • Replacing Django admin at /tmt/ — /tmt/ remains available for raw DB access.
    • Org-level admin features (billing management, org settings override) — future spec.
    • Dashboard with graphs/charts — v1 is numbers and tables. Charts in v2.
    • Full impersonation session management — v1 is a simple JWT impersonation token.

4. Data Model

4.1 JWT Token Claims

Django simplejwt already includes user attributes in the token. Extend the token serializer to include is_staff and is_superuser:

# backend/apps/accounts/serializers.py — TokenObtainPairSerializer
@classmethod
def get_token(cls, user):
    token = super().get_token(user)
    token["is_staff"] = user.is_staff
    token["is_superuser"] = user.is_superuser
    return token

4.2 No New Models

HQ is read-only + operational actions. No new database models. The audit log uses the existing AuditLog model.

4.3 Impersonation Token

When a superadmin impersonates a user, a short-lived JWT (5-minute TTL) is issued for the target user with an impersonated_by claim. This token is not refreshable. The action is audited.

5. API Contract

All endpoints require Authorization: Bearer <token> with is_staff=True.

MethodPathAuthDescription
GET/api/hq/dashboard/staffReturns metrics JSON
GET/api/hq/analytics/staffGrowth analytics: zero-filled daily series (org_signups, user_signups, pulses, active_users) over ?days= (7–90, default 30), plus subscription_breakdown and top_orgs (top 10 by pulses in window)
POST/api/hq/notifications/test-email/staffSend test email. Body: {user_id?, email?, subject?} — resolves User → email if user_id given
POST/api/hq/notifications/test-push/staffSend real device push. Body: {user_id (preferred), member_id? (back-compat), title?, body?} — returns {ok, devices, sent} or {ok: false, detail: "no active devices"}
GET/api/hq/orgs/staffList orgs. Query: ?search=&ordering=&page=. Rows include member_count (active), subscription_status (latest sub), last_pulse_date — annotated, no N+1
GET/api/hq/orgs/{id}/staffOrg detail with member count, subscription, last pulse
GET/api/hq/users/superuserList users. Query: ?search=&ordering=&is_staff=&page=. Rows include org_count, last_login
GET/api/hq/users/{id}/superuserUser detail
PATCH/api/hq/users/{id}/superuserToggle is_staff. Body: {is_staff: bool}
POST/api/hq/users/{id}/impersonate/superuserReturns impersonation JWT. Response: {access, refresh?}
GET/api/hq/audit/superuserCross-org audit log. Query: ?org=&action=&actor=&from=&to=&page=. Rows include org_name, actor_email, detail (metadata → target fallback)
GET/api/hq/system/superuserSystem status: Django version, Python, LLM provider, Redis, DB, Celery, deploy timestamp

Permission Classes

# backend/apps/hq/permissions.py
class IsStaff(permissions.BasePermission):
    def has_permission(self, request, view):
        return bool(request.user and request.user.is_staff)

class IsSuperuser(permissions.BasePermission):
    def has_permission(self, request, view):
        return bool(request.user and request.user.is_superuser)

These check the Django User model fields directly — they are available on request.user after JWT authentication (simplejwt sets request.user from the token’s user_id).

6. UI / UX

6.1 Routes

All routes under /hq:

RoutePage ComponentAccessDescription
/hqHqDashboardPagestaffMetrics dashboard with stat cards
/hq/notificationsHqNotificationsPagestaffTest email/push forms
/hq/orgsHqOrgsPagestaffOrg list with search table
/hq/orgs/:idHqOrgDetailPagestaffOrg detail: members, subscription, pulse
/hq/usersHqUsersPagesuperadminUser list with search, is_staff toggle
/hq/users/:idHqUserDetailPagesuperadminUser detail, impersonate action
/hq/auditHqAuditPagesuperadminCross-org audit log with filters
/hq/systemHqSystemPagesuperadminSystem config & service status

6.2 Protection

// App.tsx
<Route element={<HqLayout />}>
  <Route path="/hq" element={<HqDashboardPage />} />
  <Route path="/hq/notifications" element={<HqNotificationsPage />} />
  <Route path="/hq/orgs" element={<HqOrgsPage />} />
  <Route path="/hq/orgs/:id" element={<HqOrgDetailPage />} />
  <Route path="/hq/users" element={<HqUsersPage />} />
  <Route path="/hq/users/:id" element={<HqUserDetailPage />} />
  <Route path="/hq/audit" element={<HqAuditPage />} />
  <Route path="/hq/system" element={<HqSystemPage />} />
</Route>

HqLayout checks isStaff from the auth store. If !isStaff, redirect to /work/today. For superadmin-only pages, the page component itself checks isSuperuser and shows a 403 or redirects.

6.3 HqLayout — Sidebar

HqLayout (frontend/src/layouts/HqLayout.tsx) renders a fixed sidebar with a darker background to visually distinguish the admin surface from the main app:

Design tokens:

  • Sidebar background: bg-slate-800 (#1E293B) — darker than main app (#FFFFFF / bg-white)
  • Sidebar text: text-slate-100 for nav items, text-slate-400 for section labels
  • Nav item hover: hover:bg-slate-700
  • Active nav item: bg-slate-700 with left border accent (border-l-2 border-blue-400)
  • Main content area: bg-[#FAF8F4] — matches the main app background tone

Sidebar layout:

┌──────────────────────────┐
│  HQ                      │  ← fixed top, border-b border-slate-700
│  Admin HQ (title)        │     uses t('title')
├──────────────────────────┤
│                          │
│  ◈ Dashboard             │  ← all staff users see these
│  ◈ Organizations         │
│                          │
│  ── Superadmin ────────  │  ← hidden unless isSuperuser:
│  ◈ Users                 │     conditional render with isSuperuser check
│  ◈ Audit Log             │
│  ◈ System                │
│                          │
│  ◈ Notifications (test)  │  ← staff only (test tools)
│                          │
├──────────────────────────┤
│  ← Back to Lumi          │  ← Link to /work/today, uses t('back_to_lumi')
│                          │     icon: ArrowLeft
└──────────────────────────┘

Behavior:

  • Superadmin nav section is conditionally rendered with isSuperuser from the auth store — staff-only users see only Dashboard, Organizations, Notifications, and Back to Lumi.
  • Active nav item is highlighted with a blue left border accent (border-l-2 border-blue-400).
  • “Back to Lumi” always navigates to /work/today (not a simple back navigation) to exit the admin context entirely.
  • The sidebar width is fixed at w-56 (14rem / 224px).

6.4 Dashboard — Stat Cards

HqDashboardPage displays 6 stat cards in a 2-row × 3-column responsive grid.

Card design:

┌─────────────────────┐
│ Total Organizations │  ← label (text-xs text-slate-500 uppercase)
│       47            │  ← value (text-3xl font-bold)
│       7 days │      │  ← subtitle (text-xs text-muted-foreground)
└─────────────────────┘

Grid layout:

Row 1: [Total Orgs] [Total Users] [Active Orgs (7d)]
Row 2: [DAU (today)] [API Errors (24h)] [Celery Queue]
  • Cards use the Card, CardHeader, CardContent, CardTitle shadcn/ui components from frontend/src/components/ui/card.
  • Each card shows: label (i18n key), value (fetched from GET /api/hq/dashboard/), and an optional subtitle/context.
  • Loading state: each card shows a Skeleton component while data loads.
  • Empty/zero state: show “0” — zero is valid data, not an error.
  • Error state: if the API call fails, cards show ”—” and a toast with t('error_load').
  • Responsive: 3 columns on large screens, 2 on medium, 1 on small.

HqOrgsPage renders a searchable, sortable table of all organizations in the system.

Search bar: An <Input> component above the table with a search icon. As the user types, results filter client-side (or debounced server-side via ?search= query param).

Table columns:

ColumnSourceSortable
Nameorganization.name✅
Membersorganization.member_count✅
Subscriptionorganization.subscription_status✅
Last Pulseorganization.last_pulse_date✅
Createdorganization.created_at✅
  • Uses the shadcn/ui <Table> component from frontend/src/components/ui/table.
  • Rows are clickable: navigating to /hq/orgs/:id for detail view.
  • Empty state: “No organizations found” with illustration if search yields no results.
  • Loading state: Skeleton rows matching the table structure.
  • Pagination: server-side paginated (page size 20), with pagination controls at the bottom.

6.6 Superuser-Only Sections

Superadmin-only pages (Users, Audit Log, System) follow these UI rules:

  • Nav hiding: The sidebar section divider ”── Superadmin ──” and the 3 links below it are conditionally rendered only when isSuperuser === true in useAuthStore.
  • Direct URL access: If a staff-only user navigates directly to /hq/users, the page component renders t('superadmin_only') with a link back to /hq. No flash of superadmin UI.
  • Superadmin pages include: A small badge at the top of each page: t('superadmin_only') in a yellow/warning badge style, to remind the user they’re in a privileged area.

6.7 i18n — hq Namespace

All hq namespace keys with their en and vi values:

KeyEnglishVietnamese
hq.titleAdmin HQTrung tâm vận hành
hq.dashboardDashboardBảng điều khiển
hq.organizationsOrganizationsTổ chức
hq.usersUsersNgười dùng
hq.auditAudit LogNhật ký kiểm tra
hq.systemSystemHệ thống
hq.notificationsNotifications (test)Thông báo (kiểm thử)
hq.back_to_lumiBack to LumiVề Lumi
hq.impersonateImpersonateMạo danh
hq.test_email_sentTest email sentEmail kiểm thử đã gửi
hq.test_push_sentTest push sentPush kiểm thử đã gửi
hq.staff_onlyStaff access onlyChỉ nhân viên truy cập
hq.superadmin_onlySuperadmin access requiredYêu cầu quyền quản trị viên cấp cao
hq.error_loadCould not load HQ metrics — check your connection and try again.Không tải được số liệu HQ — kiểm tra kết nối và thử lại.
hq.metric_total_orgsTotal organizationsTổng tổ chức
hq.metric_total_usersTotal usersTổng người dùng
hq.metric_active_orgsActive orgs (7 days)Tổ chức hoạt động (7 ngày)
hq.metric_dauDaily active usersNgười dùng hoạt động hôm nay
hq.metric_api_errorsAPI errors (24h)Lỗi API (24h)
hq.metric_celery_queueCelery queueHàng đợi Celery
hq.disconnectedDisconnectedMất kết nối

All keys are used via useTranslation('hq') and t('key_name') — no hardcoded English strings in JSX.

Existing locale files at frontend/src/i18n/locales/{en,vi}/hq.json already contain all 21 keys listed above.

7. Test Plan

#CaseTypeWhere
1Non-staff user redirected from /hq to /work/todayfrontend xfailhq.test.tsx::staff gate
2Staff user can access /hq and see dashboardfrontend xfailhq.test.tsx::dash metrics
3HqLayout renders darker slate-800 sidebarfrontend xfailhq.test.tsx::route rendering
4Staff (not superuser) sees limited nav (no users/audit/system)frontend xfailhq.test.tsx::superuser nav
5Dashboard shows org count and member countfrontend xfailhq.test.tsx::dash metrics
6Superadmin can see Users nav linkfrontend xfailhq.test.tsx::user listing
7Superadmin can see Audit Log nav linkfrontend xfailhq.test.tsx::audit log
8Org listing nav link renders for stafffrontend xfailhq.test.tsx::org listing
9Non-staff user redirected from /hq to /work/todaye2edescribe("Admin HQ")
10Staff user can access /hq and see dashboarde2esame block
11Staff user redirected from /hq/users (superadmin-only)e2esame block
12Superadmin can access /hq/users and see user liste2esame block
20staff@demo.local can access /hq — heading visible, URL stays /hqe2ecross-cutting-2.spec.ts › F43 — Admin HQ (staff user)
21staff@demo.local sees HQ sidebar nave2ecross-cutting-2.spec.ts › F43 — Admin HQ (staff user)
22sales@demo.local (non-staff) is redirected from /hqe2ecross-cutting-2.spec.ts › F43 — Admin HQ (staff user)
23Unauthenticated /hq visit redirected to logine2ecross-cutting-2.spec.ts › F43 — Admin HQ (staff user)
24staff@demo.local can access /hq/jobs (Recruitment ATS tab)e2ecross-cutting-2.spec.ts › F43 — Admin HQ (staff user)
13Superadmin can toggle is_staff on a userbackendtests/test_hq.py::TestUserDetail
14Superadmin can impersonate a userbackendtests/test_hq.py::TestImpersonate
15Impersonation token is short-lived and non-refreshablebackendsame class
16Test email sends successfully via /api/hq/notifications/test-email/backendtests/test_hq.py::TestNotifications
17Dashboard metrics return correct countsbackendtests/test_hq.py::TestDashboard
18Cross-org audit log is accessible to superadmin onlybackendsame class
19JWT token includes is_staff and is_superuser claimsbackendtests/test_auth.py

8. Current Behavior

  • Django admin at /tmt/ — unskinned, functional for raw DB access. Requires is_staff=True to log in.
  • No /hq route in the React SPA.
  • JWT tokens do not include is_staff or is_superuser claims (simplejwt’s default get_token only includes user_id).
  • No admin-specific API endpoints.
  • Frontend useAuthStore only stores isAuthenticated, access_token, refresh_token, organization_id, and user.
  • No role differentiation in the frontend beyond isAuthenticated.

9. Error States

ScenarioBehavior
Non-staff hits /hq/*Redirect to /work/today. No flash of admin UI.
Staff hits superadmin-only routePage shows “Superadmin access required” with a link back to /hq.
JWT expired while in /hqStandard 401 → refresh flow → if refresh fails, redirect to /login.
Impersonation token expiresRedirect to /hq/users with toast “Impersonation session expired.”
Test email failsShow error toast with SMTP error message. Do not crash.
Celery/Redis down for dashboardShow “Disconnected” badge next to queue count.

10. Diagrams & Docs Touched

  • frontend/DIAGRAMS.md: add /hq route group, HqLayout component, role-based route guards.
  • backend/DIAGRAMS.md: add apps/hq/ app, request lifecycle with IsStaff/IsSuperuser permission checks.
  • backend/apps/hq/ — new Django app.

11. Implementation Audit

Edge Cases Catalog (formerly §9, 2026-06-14; 3 items): cross-tenant staff mutation guard, high-risk action second-confirmation (impersonation 5-min TTL), partial-permission redacted cards. Full catalog in git history.

#ClaimStatusEvidence
1JWT claims is_staff + is_superuser in login/register/refresh tokens✅ matchaccounts/views.py L64-65 (login), L104-105 (register), L174-175 (refresh); test_api.py::TestTokenClaims (4 tests)
2apps/hq/ Django app with 11 endpoints (dashboard, org list/detail, user list/detail/toggle/impersonate, audit, system, test-email, test-push)✅ matchbackend/apps/hq/urls.py, views.py, permissions.py; tests/test_hq.py (57 tests)
3IsStaff + IsSuperuser permission classes gating HQ routes✅ matchbackend/apps/hq/permissions.py
4Frontend auth store decodes JWT, stores isStaff + isSuperuser✅ matchauthStore.ts; authStore.test.ts
5HqLayout + 8 HQ pages (Dashboard, Orgs, Users, Audit, System, Notifications + detail views)✅ match13 Hq*.tsx files in frontend/src/pages/hq/; frontend/src/test/hq.test.tsx (11 tests)
6E2E tests for Admin HQ✅ matchdescribe("Admin HQ") in lumi.spec.ts
7i18n hq namespace in en + vi✅ match62 locale keys confirmed by registry
8Non-staff redirect and superadmin-only route guard enforced✅ matchHqLayout permission guard + backend/apps/hq/permissions.py::IsSuperuser

12. Open Questions / Decisions

  1. URL path: /hq vs /admin vs /staff? Decision: /hq — short, distinct, doesn’t conflict with Django’s /admin (which we don’t use; we use /tmt/), and reads well as “headquarters.”
  2. Impersonation scope: Full-session impersonation vs single-request? Decision: JWT-based session with 5-minute TTL. The superadmin gets a token for the target user. They use it in a separate browser tab/profile. No “switch back” mechanism in v1 — the token expires and they refresh to return to their own session.
  3. Dashboard metrics source: Real-time queries vs. cached? Decision: real-time queries for v1. The metrics are simple COUNT queries (not aggregates over millions of rows). Add caching in v2 if needed.
  4. Sentry integration: Should the dashboard show Sentry issues? Decision: show a link to Sentry in v1. Inline issues in v2 when Sentry API integration is built.
  5. is_staff assignment: Who can grant staff access? Decision: superadmin only, via the /hq/users/{id} page (toggle is_staff flag). New users are never staff by default.

13. Bug / Change Log

2026-07-10 — W-HQ-05: System page showed dishonest/blank status (real bug, WU7)

Type: Bug fix (FE↔BE contract mismatch, Amodei honest-AI violation)

Summary:

  • GET /api/hq/system/ matched §5’s documented contract in name only: it returned only {db: "ok"|"error", celery, debug, version} — no django_version, python_version, llm_provider, redis, or last_deploy. The frontend read fields (db_status, celery_status, redis_status) the backend never sent, so every boolean coerced to false and rendered “Down” for a healthy DB serving the very request — dishonest per SOUL.md/GOAL.md’s Amodei test.
  • system_view now returns the full §5 contract: django_version (django.get_version()), python_version (platform.python_version()), llm_provider (settings.LLM_PROVIDER), redis (a real 1s-timeout redis.from_url(...).ping(), honestly "unavailable" when unreachable — legitimate in local dev), and last_deploy (BUILD_TIME env var stamped by .github/workflows/deploy-{dev,prod}.yml; null/honest "—" when unset, never a fabricated “just now”).
  • HqSystemPage.tsx now types the response as the real "ok" | "error" | "unavailable" string union (not booleans) and maps db/celery/redis directly — a field the backend omits renders "—", never a fabricated "Down".
  • Tests: 9 new cases in TestSystemView (backend/tests/hq/test_hq.py) — all fields present, db-ok-when-healthy, redis ok/unavailable branches, real django/python versions, configured LLM provider, last_deploy honest-null vs real-BUILD_TIME. 2 new frontend cases in frontend/src/test/hq/HqSystemPage.test.tsx pin “never renders Down for a healthy dependency” and “renders Down only for a genuinely-down one, — for absent last_deploy.”
  • No i18n changes — django_version, python_version, llm_provider, redis_status, db_status, celery_status, deploy_timestamp, status_ok, status_down keys already existed in en/vi hq.json from the original (unwired) UI.

2026-07-10 — Analytics page polish: KPI deltas, semantic status colors, ranked top-orgs list

Type: Improvement (five-veto pass on the new analytics page)

Summary:

  • GET /api/hq/analytics/ response gains previous — totals over the prior window of equal length (org_signups, user_signups, pulses, and active_users as peak daily distinct, matching the headline KPI semantics). KPI cards now show % deltas (“+38%” green / “−12%” red / “new” when the prior window was empty) with a “vs prior Nd” caption.
  • Subscription donut + legend use semantic status colors (active=green, trialing=blue, grace=amber, read_only=red, cancelled=muted) instead of palette order; legend labels reuse billing.status_* translations.
  • Top orgs switched from a vertical bar chart (only 2–3 of 10 axis labels fit at 390px) to a ranked list with proportional bars — readable at 320.
  • Tests: test_previous_window_totals (+zero-history case) backend; delta + ranked-list cases frontend. 2 new hq.* i18n keys propagated to 61 locales.

2026-07-10 — HQ analytics page (charts)

Type: Feature (owner-requested)

Summary:

  • New staff-only endpoint GET /api/hq/analytics/ — zero-filled daily series (org signups, user signups, pulses, distinct active pulse users) over a clamped ?days= window (7–90, default 30), subscription-status breakdown, top 10 orgs by pulse volume. Allowlisted in the tenant-isolation guard (cross-org staff read).
  • New route /hq/analytics (HqAnalyticsPage) + HQ nav link: KPI strip (2-col mobile / 4-col xl) + six chart cards (1-col mobile / 2-col md) using the shared Lumi chart kit (LineChartWidget, PieChartWidget + inline legend, BarChartWidget); 7/30/90-day window switcher.
  • i18n: 13 new hq.* keys in en/vi, propagated to all 61 locales.
  • Tests: TestHqAnalytics (6) in backend/tests/hq/test_hq_analytics.py; hqAnalytics.test.tsx (4) frontend.
  • Route trees in frontend/DIAGRAMS.md updated.

2026-07-10 — HQ list payload enrichment (mobile QC follow-up to PR #1586)

Type: Bug fix (API contract drift)

Summary:

  • The HQ admin tables rendered columns the list endpoints never sent, so cells showed — at every width: orgs lacked member_count/subscription_status/last_pulse_date; users lacked org_count/last_login; audit lacked org_name/actor_email/detail.
  • orgs_list_view now annotates member count (active members), latest subscription status, and last pulse date via Count/Subquery — single query, no N+1.
  • users_list_view annotates org_count and appends last_login; enriched in the HQ view (not the shared UserSerializer) so the fields don’t leak into accounts endpoints.
  • audit_log_view gains select_related("organization", "actor__user") plus org_name, actor_email, and detail (compact metadata JSON → target_type target_id fallback → empty).
  • Tests: TestHqListPayloadEnrichment (7 tests) in backend/tests/hq/test_hq.py pins the payload contract.

2026-07-02 — Fix §11 stale line numbers, test count, and file path

Type: Documentation

Summary:

  • §11 Row 1 evidence: corrected accounts/views.py line ranges from L61-62/L95-96/L162-163 to L64-65 (login), L104-105 (register), L174-175 (refresh). Verified by grep -n 'is_staff\|is_superuser' backend/apps/accounts/views.py.
  • §11 Row 2 evidence: test count updated from 34 → 57 (grep -c 'def test_' backend/tests/test_hq.py).
  • §11 Row 5 evidence: Hq*.tsx count updated from 11 → 13; path corrected from frontend/src/pages/ to frontend/src/pages/hq/; test file path and count corrected from hq.test.tsx (7 tests) to frontend/src/test/hq.test.tsx (11 tests).
  • No code changes.

2026-07-10 — /hq/notifications: user-picker test sends (fix three latent bugs)

Type: Bug fix + feature

Summary:

  • (a) Added ?search= filter to GET /api/hq/users/ — icontains on email OR name, pagination preserved, superuser-only unchanged.
  • (b) test_email_view now accepts optional user_id (resolves User → email server-side); raw email field kept for backward compat; 404 on unknown user_id.
  • (c) test_push_view REWRITTEN: sends real device push via send_push_to_member instead of send_email_with_log. Accepts user_id (preferred) or member_id (back-compat). Resolves the user’s OrganizationMembers, counts active DeviceTokens, returns {ok, devices, sent} or {ok: false, detail: "no active devices"} when zero active tokens. Staff-only + audit log.
  • Frontend: replaced raw email/device_token fields with a shared debounced user search picker (GET /api/hq/users/?search=…, shows email + name). Email card keeps an optional “use custom email” collapsed input. Push card shows no-active-devices error. hqApi.testPush now sends {user_id, title?, body?}; hqApi.testEmail sends {user_id?, email?, subject?}.
  • Tests: 19 backend tests in TestNotifications (search filter, email-by-user_id, push-by-user_id happy path, no-devices, back-compat member_id, non-staff 403). 4 frontend unit tests.
  • i18n: added search_user, searching, select_user_first, use_custom_email keys to en + vi hq.json (and all other locales).

Type: Documentation

Summary:

  • 13 of 15 [ ] ACs in §2 flipped to [x] — confirmed shipped against §11 audit rows 1–8 (all ✅).
  • 2 ACs remain [ ]: Sentry issues inline display (deferred to v2 per §12 decision 4 — v1 shows a link only) and the F85 pilot tab (no §11 evidence; pilot endpoints not listed in §11 Row 2’s 11-endpoint set).
  • No code changes.

2026-06-15 — Frontend route namespaced

Frontend route namespaced: “Back to Lumi” / non-staff redirect target /today -> /work/today (app-wide IA restructure; /hq and API paths unchanged).

2026-06-14 — Spec restructured to TEMPLATE.md canonical order (§1–§13)

Type: Documentation Change

Summary:

  • §9 Edge Cases (3 items) absorbed into §11 Implementation Audit note.
  • §12 Implementation Audit converted from bullet/narrative to canonical 4-column table (8 rows, all ✅).
  • Sections renumbered: §10 ES → §9, §11 Diags → §10, §12 Audit → §11, §13 OQ → §12, §14 BL → §13.

Reason:

  • Align with TEMPLATE.md canonical section order (§1–§13) per SP fleet restructuring pass.

Impact:

  • Spec authors and future implementation agents.

None yet — spec not implemented.