Spec: F28 — Company Hub (announcements, policies, HR docs)
Status: shipped (2026-06-11)
1. Problem & CEO Job
See clearly — in both directions. The pulse (F07) is the upward channel: the org talks, the CEO hears. There is no downward channel: CEO announcements die in Zalo groups, the leave policy lives in someone’s Word file, new hires learn rules by asking around. The Hub completes the loop — announcements with read-tracking (“ai chưa đọc?”) and a living policy/HR document library.
What it is NOT (the line that keeps this from being garbage): not an internal social network. No feeds with comments, no likes-economy, no threads. The org’s conversation happens in pulses and chat apps; the Hub is the org’s memory and megaphone: write once, reach everyone, know who read it. One reaction exists — “Đã đọc ✓” — because that’s the only one the CEO acts on.
2. User Stories & Acceptance Criteria
- As a CEO, I want to announce once and see who’s seen it, so that “tôi đã thông báo rồi mà” stops being an argument.
- Announcement: title + markdown body + audience (org or specific teams) + optional pinned. Publishing notifies via Telegram (digest discipline — bundled, never one ping per post).
- Acknowledgement is one tap; the author sees read stats (n/total, who’s missing) — visibility, not surveillance: ack data visible to author + admins only.
- Pinned announcements surface at the top of the Hub and (max 1) as a banner item on Today until acknowledged.
- As an HR/admin, I want policies as versioned living pages, so that there is exactly one current truth.
- Doc: title + markdown body + category (policy / HR / guide / other), status draft→published, integer version bumped on publish; prior versions readable (audit trail), only latest is “current”.
- Editing rights: admin+; reading: all members (team-scoped docs out of v1 — secrecy needs invite a permission swamp).
- As a new employee, I want to find “nghỉ phép thế nào?” myself, so that onboarding doesn’t depend on asking the right person.
- Hub has search over titles + bodies (DB search is enough at SME scale).
- (Phase-2.5 glue, flagged not promised:) Chat (F14) real-mode answers policy questions from published docs with citation — the Hub is what makes Lumi’s chat answer like an operating manual. Design docs as retrievable from day one (clean markdown, one topic per doc).
- Content remains markdown-first; optional file attachments reuse the existing F34 upload custody and never replace the readable page as the source of truth.
- On native Company Hub, a partial summary keeps successfully loaded counts visible, marks failed sections as unavailable, and offers an explicit retry instead of presenting failed requests as confirmed zeroes; a total outage preserves the last-known-good summary during refresh and uses the authored error/retry path on first load.
- On native Hub, switching from Announcements to Documents keeps exactly one page header: the Hub header and tabs remain visible, while the embedded Documents content omits its standalone title. Opening Documents as its own routed screen still shows its title and back action.
3. Scope Boundaries
- In: announcements (publish, audience, pin, ack tracking, Telegram delivery), document library (markdown, categories, versioning, search), optional F34-backed file attachments, Today banner glue.
- Out (hard): comments/threads/reactions beyond ack, leave/payroll workflows (the Hub stores policy documents but never processes them), attendance capture/export (owned by F106), external sharing, doc-level permissions, WYSIWYG complexity beyond a basic markdown editor.
4. Data Model (as built — app hub)
Announcement— org FK, author (OrganizationMember), title, body_md, audience (org| team FKs M2M), pinned, published_at, status draft/published,attachments(M2M →uploads.Upload, blank=True).Acknowledgement— announcement FK, member FK, ack_at; unique (announcement, member).Doc— org FK, title, body_md, category (policy/hr/guide/other), version int, status draft/published, updated_by,attachments(M2M →uploads.Upload, blank=True);DocVersion(doc FK, version, body_md, published_at, published_by) for history.
ER → backend/DIAGRAMS.md · GLOSSARY: Announcement, Doc, Acknowledgement.
5. API Contract (as built)
| Method | Path | Min role | Notes |
|---|---|---|---|
| CRUD | /api/announcements/ | manager (write) / member (read) | List respects audience; ?unread=me |
| POST | /api/announcements/<id>/ack/ | member | Idempotent |
| GET | /api/announcements/<id>/acks/ | author/admin | Read stats |
| CRUD | /api/hub-docs/ | admin (write) / member (read) | Publish bumps version, snapshots DocVersion |
| GET | /api/hub-docs/search/?q= | member | Title+body search |
Tenant-scoped throughout; audience filtering server-side.
Attachment write field (attachment_ids): Both POST /api/announcements/ and PATCH /api/announcements/<id>/, plus POST /api/hub-docs/ and PATCH /api/hub-docs/<id>/, accept an optional write-only attachment_ids list of Upload UUIDs (produced by the existing F34 POST /api/uploads/presign → S3 → POST /api/uploads/<id>/complete flow). Each UUID is validated to exist and belong to the caller’s org — cross-tenant or nonexistent IDs return 400. On PATCH, omitting attachment_ids leaves existing attachments unchanged; sending [] clears them.
Attachment read field (attachments): All announcement and doc read responses include an attachments array. Each element: {id, filename, content_type, byte_size, url} where url = Upload.public_url (the durable S3/CDN URL). No new serving endpoint — inline images in body_md embed Upload.public_url directly. Field shape mirrors the F81 comment upload_* fields for a consistent contract.
Path note (PR-2):
/api/docs/is reserved by drf-spectacular’s Swagger UI (config/urls.py’sswagger-uiroute predates this app), so the Doc API is mounted at/api/hub-docs/instead of the literal path above.
6. UI / UX
- New route
/hub— two sections, one page: announcements (reverse-chron, pinned first) and documents (by category). No dashboard, no widgets. - Reading is the design target: clean typographic page (DESIGN.md type scale), comfortable on a phone — policies get read in lines at the bank, not at desks.
- Ack affordance is one fat button at the end of the announcement (“Đã đọc ✓”) — placement forces the scroll-through.
- States: empty hub teaches (“Đăng thông báo đầu tiên — cả công ty sẽ thấy ở đây, không trôi như Zalo”); draft vs published clearly distinct for authors.
- i18n:
hubnamespace, en+vi (all 62 locales in parity via i18n.test.ts). - WYSIWYG editor (2026-07-02):
ComposeAnnouncementandDocEditornow use<MarkdownEditor>(frontend/src/components/editor/MarkdownEditor.tsx) — a TipTap WYSIWYG that reads/writesbody_md. Toolbar covers Bold, Italic, H1, H2, Bullet list, Ordered list, Inline code, Code block, Insert link, Insert image. Content is always stored as Markdown (no HTML inbody_md); the mobile KMP renderer depends on this. - Inline image upload (2026-07-02): Toolbar “image” button, paste, and drag-drop trigger the shared
uploadFile()helper (frontend/src/lib/upload.ts— extracted fromuseUpload, no duplication). Image resolves topublic_urland is inserted as a native image node in the editor (renders visually) and serialised toinbody_md. - File attachments — compose (2026-07-02):
<AttachmentList>(frontend/src/components/editor/AttachmentList.tsx) below the editor in both compose surfaces. Uploads each file viauploadFile(), accumulatesattachment_idspassed toPOST /api/announcements/andPOST/PATCH /api/hub-docs/. - File attachments — view (2026-07-02):
<AttachmentsRow>(frontend/src/components/editor/AttachmentsRow.tsx) rendersattachments[]from the API response as downloadable links inAnnouncementCardandDocViewer. - Native Company Hub summary (2026-08-03): the shared KMP summary records which of the parallel messages, announcements, or members requests failed. The Hub keeps the other counts visible, labels unavailable rows and summary metadata, and exposes a localized retry action on the partial-data banner. Stale snapshots shown during retry or total outage do not replay an old partial warning as current request truth.
- Native Hub Documents chrome: the Documents list supports two presentation contexts. Embedded inside the Hub tab it reuses the Hub page title and tab chrome; as a standalone routed destination it owns its Documents title and back action. Content, permissions, empty/error states, refresh, and document actions are identical in both contexts.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Announcement lifecycle, audience filtering (team-scoped invisible to others), cross-tenant | backend | new classes in backend/tests/ |
| 2 | Ack idempotency; stats visibility limited to author/admin | backend | same |
| 3 | Doc versioning: publish snapshots, latest-wins, history readable | backend | same |
| 4 | Search returns published only | backend | same |
| 5 | Post → ack → stats flow; doc read | e2e | new describe("Hub") |
| 6 | Native Company Hub partial response preserves loaded counts, marks unavailable sections, and retries | shared KMP unit + structural UI | CompanyHubHomeRepositoryTest; MobileUxInvariantsTest |
| 7 | Native Hub Documents renders one header when embedded and retains its header when routed standalone | shared KMP structural UI | MobileUxInvariantsTest.hubDocumentsUsesExactlyOneOwningHeaderPerPresentationContext |
8. Current Behavior
Shipped across P06 PR-1..PR-3 (2026-06-11).
- PR-1 (backend models + announcements API):
Announcement,Acknowledgementmodels;/api/announcements/CRUD,/api/announcements/<id>/ack/(idempotent),/api/announcements/<id>/acks/(author/admin only). Audience-scoped list. Tenant isolation via org FK. Pinned filter?pinned=true/false(added in A-r07 bug fix — see §13). - PR-2 (docs API + Celery digest):
Doc/DocVersionmodels;/api/hub-docs/CRUD withpublish/history/searchactions. Publish bumps integer version and snapshots body intoDocVersion. Evening digest (apps.hub.tasks.send_hub_digest) via F21 Telegram plumbing — one bundled message per org per evening atDIGEST_HOUR=18, timezone-aware, checked every 15 min viaCELERY_BEAT_SCHEDULE["hub-evening-digest"]. Doc version history pagination added (C-r24 bug fix — see §13). - PR-3 (frontend):
/hubroute —HubPage(tabs: announcements/docs),AnnouncementsTab+ComposeAnnouncement+AnnouncementCard(publish, ack, read-stats for managers),DocsTab+DocEditor+DocViewer(publish, history, search). SharedMarkdowncomponent (frontend/src/components/markdown/Markdown.tsx,marked+dompurify) renders all body markdown — script/HTML injection neutralized.TodayPageshows pinned-announcement banner (“Xem trong Hub” CTA) until acked, max 1.hub/sidebari18n namespaces complete (en+vi). Manager/admin role detection fixed inLayout.tsx—loadOrganizations()now runs alongsideloadOrganization()on session bootstrap souseAuthStore().organizationsis populated after page reload. 6 E2E tests indescribe("Hub"). - Native Company Hub (2026-08-03): the shared Android/iOS lane summary loads messages, announcements, and members concurrently. Each result now carries failure provenance so a failed request cannot masquerade as an empty section; the shared screen renders the remaining data with a partial-data warning and retry action.
- Native Hub Documents (2026-08-09):
HubScreenembedsHubDocsScreenwith the child page header disabled, so the parent Hub title and segmented tabs remain the only page chrome. DirectHubDocsScreencallers keep the default standalone title and back action.
9. Error States
| # | Error | User message | HTTP | Logged | Notes |
|---|---|---|---|---|---|
| ES1 | Cross-tenant announcement access — member of org A tries to access org B’s announcement UUID. | Standard 404 | 404 | No | AnnouncementViewSet queryset filters by request.tenant. |
| ES2 | Cross-tenant ack — member of org A tries to ack an announcement from org B. | Standard 404 | 404 | No | Same queryset scoping. |
| ES3 | Member creates announcement — member (not manager/admin) calls POST /api/announcements/. | No announcement created | 403 | No | Gated by IsOrgManager permission class. |
| ES4 | Non-admin creates doc — manager (not admin) calls POST /api/hub-docs/. | No doc created | 403 | No | Gated by IsOrgOwnerOrAdmin permission class. |
| ES5 | Member sees draft announcement — a published announcement is edited back to draft. | The member’s list query filters status=published — draft announcements are invisible to non-admins. | 200 (empty) | No | TestAnnouncement::test_draft_invisible_to_members. |
| ES6 | Search with empty query — GET /api/hub-docs/search/?q= (empty string). | Returns all published docs (no filter applied). The frontend SearchBar should not fire the search API without a non-empty query. | 200 (all docs) | No | TestHubSearch::test_search_empty_query_returns_published_docs. |
| ES7 | Ack stats for deleted announcement — GET /api/announcements/<uuid>/acks/ on a deleted announcement. | Standard 404 | 404 | No | get_object_or_404 behavior. |
| ES8 | Ack stats for non-author member — a member who is not the announcement author calls the stats endpoint. | announcements.error_stats_forbidden | 403 | No | TestAcknowledgement::test_stats_endpoint_forbidden_for_non_author_member. |
| ES9 | Native Company Hub partial load — one or more parallel summary requests fail while others succeed. | Loaded sections remain visible; failed sections say that data is unavailable; the partial-data banner offers retry. | N/A | Non-transient failures | CompanyHubSummary.unavailableSections preserves failure provenance; no failed section is presented as a confirmed zero. |
| ES10 | Native Company Hub total outage — all parallel summary requests fail during first load or refresh. | First load shows the authored error/retry path; refresh keeps the last-known-good summary under the error banner instead of replacing it with unavailable zeroes. An old partial warning is not replayed as current request truth. | N/A | Non-transient failures | CompanyHubHomeScreenModel maps an all-sections-unavailable summary to LoadState.Error(last=...); CompanyHubLaneScreen marks stale content explicitly. |
10. Diagrams & Docs Touched
backend/DIAGRAMS.md (ER) · frontend/DIAGRAMS.md (route) · GLOSSARY · F21 spec (Telegram delivery hook) · F13 spec (Today banner) — same-PR updates.
11. Implementation Audit (2026-07-14)
The canonical Contract Graph declaration is contracts/features/F28.cue. The current code-vs-doc audit is F28-company-hub-audit.md. The API contract owns the 19 backend Hub endpoints and their collected tenant/role/content proofs. Browser, native, and live Telegram evidence remain explicit gaps; this slice does not infer them from source, unit tests, or route presence.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Announcement model: org FK, author (OrganizationMember), title, body_md, audience, pinned, published_at, status draft/published | ✅ match | §4 data model; P06 PR-1; backend/apps/hub/models.py |
| 2 | Acknowledgement model: announcement FK, member FK, ack_at; unique(announcement, member) | ✅ match | §4; TestAcknowledgement::test_ack_idempotent verifies idempotency |
| 3 | Doc/DocVersion models: publish bumps integer version + snapshots DocVersion | ✅ match | P06 PR-2; TestDoc::test_publish_bumps_version_and_snapshots |
| 4 | All 5 endpoints: announcements CRUD, /ack/, /acks/, hub-docs CRUD, hub-docs/search — tenant-scoped | ✅ match | §5 API contract; cross-tenant tests at TestAnnouncement + TestHubSearch |
| 5 | Telegram evening digest (send_hub_digest) via F21 plumbing; DIGEST_HOUR=18; per-org timezone-aware | ✅ match | P06 PR-2; apps.hub.tasks.send_hub_digest; TestHubDigest::test_digest_bundles_published_announcements_per_member |
| 6 | Markdown sanitizer — Markdown.tsx (marked + dompurify); never dangerouslySetInnerHTML raw | ✅ match | P06 PR-3; frontend/src/components/markdown/Markdown.tsx |
| 7 | /hub route: HubPage (announcement + doc tabs), pinned-first ordering | ✅ match | P06 PR-3; 6 E2E tests in describe("Hub"); TestAnnouncement::test_pinned_ordering |
| 8 | TodayPage pinned banner (max 1 pinned announcement, “Xem trong Hub” CTA) until acked | ✅ match | §2 AC; P06 PR-3; TodayPage.tsx; banner disappears after ack |
| 9 | hub/sidebar i18n namespaces (en+vi) complete | ✅ match | §6; P06 PR-3; no hardcoded strings rule satisfied |
| 10 | Manager/admin role detection fix in Layout.tsx — loadOrganizations() on session bootstrap | ✅ match | P06 PR-3; fixes compose/publish UI after page reload |
| 11 | Doc version history pagination — /api/hub-docs/<id>/history/ returns paginated dict | ✅ match | C-r24 bug fix; TestF28DocHistoryPagination::test_history_returns_paginated_response; test_history_second_page_returns_results |
| 12 | Pinned filter ?pinned=true/false on announcements list | ✅ match | A-r07 bug fix; TestAnnouncement::test_pinned_true_filter; test_pinned_true_false_filter |
| 13 | Pin limit — no hard max on Announcement.pinned; API does not enforce a count limit | ⚠️ gap | test_pinned_ordering tests order, not count; admin UI should warn but no middleware or model validation exists |
| 14 | Blank body_md validation returns 400 | ⚠️ gap | Serializer-level enforcement stated but no explicit test for blank announcement or doc body |
| 15 | Delete-while-pinned — Acknowledgement cascade removes; TodayPage banner disappears | ⚠️ gap | Standard CASCADE behavior; no explicit test for pinned-announcement deletion + banner state |
| 16 | Telegram digest truncation on mass publish — 4096-char Telegram limit handled gracefully | ⚠️ gap | TestHubDigest covers 1 announcement; no test for many-announcement digest hitting the message length limit |
Summary: 12/16 match; 4 gaps flagged (pin limit enforcement, blank body validation, delete-while-pinned test, Telegram mass-publish truncation).
12. Open Questions / Decisions
- Mandatory-read policies (block app until acked)? Authoritarian-mode default off; consider per-announcement flag only if design partners demand it.
- Doc approval flow (policy changes via F26 approval chain)? Natural glue — revisit once both ship.
13. Bug / Change Log
2026-08-15 — Materialise organizations before the Hub digest loop
Type: Production worker reliability fix
Summary:
send_hub_digest_for_timepreviously usedOrganization.objects.iterator()while each organization could perform Telegram and database work. On the dev worker this produced a PostgreSQLcursor "_django_curs_..." already existsretry when the pooled connection was recycled during the per-organization loop.- The digest now snapshots the bounded organization list before doing downstream work, matching the existing no-server-side-cursor rule used by the other long-running beat loops.
- A regression test fails if the Hub digest reintroduces
QuerySet.iterator().
Evidence boundary:
lumi-dev-celery-workerlogged one retry at the 2026-08-15 17:15 container-local tick; the task’sautoretry_forkept the failure from becoming a permanent lost digest. This is source and focused-test proof, not proof of a future live Telegram send.
2026-08-09 — Native Hub Documents owns one header per context
Type: Bug Fix
Problem: selecting Documents inside the native Company/Hub surface rendered the parent Hub page title and the standalone Documents page title together. The duplicate chrome reduced usable space and made the tab look like two stacked screens.
Fix: make Documents header ownership explicit. The embedded tab suppresses the child page title; the standalone route keeps its title and back action. No route, API, authorization, document state, or localized copy changes.
Regression: MobileUxInvariantsTest.hubDocumentsUsesExactlyOneOwningHeaderPerPresentationContext
failed before implementation and passes only when embedded and standalone contexts have distinct
header ownership. Named-device Android/iOS visual confirmation remains an explicit live-proof gap.
2026-08-03 — Native Company Hub stops hiding partial failures
Type: Bug fix (mobile trust surface)
Problem: the native Company Hub loaded messages, announcements, and members in parallel but converted every request failure into an empty list. A temporary outage could therefore render a successful-looking screen with zero counts, making absence indistinguishable from unavailable data.
Fix: the shared summary records unavailable sections, keeps successful sections readable, labels affected rows and summary metadata, and provides an explicit localized retry action. Cancellation continues to propagate through the existing safe coroutine boundary.
Tests: CompanyHubHomeRepositoryTest covers each individual failure and the all-failed case;
MobileUxInvariantsTest protects the partial-data warning and retry source contract. JVM proof is
not named-device or production evidence.
2026-08-04 — Native Company Hub keeps the last good summary through total outage
Type: Bug fix (mobile trust surface)
Problem: the partial-data repository contract correctly marked all three summary sections unavailable, but the screen model still treated that result as a successful refresh. A total outage could therefore replace a previously trustworthy Company Hub snapshot with an unavailable summary instead of preserving the last-known-good content under the existing error banner.
Fix: an all-sections-unavailable summary now follows the existing LoadState.Error(last=...)
contract. First load remains an authored error with retry; refresh keeps the prior summary visible.
Valid empty responses and partial successes remain successful states.
Tests: CompanyHubHomeScreenModelTest covers partial success, first-load total outage, refresh
total outage with stale data, and retry from a stale error. Runtime proof remains separately open.
2026-08-04 — Native Company Hub does not replay stale partial warnings
Type: Bug fix (mobile trust surface)
Problem: a partial summary retained as stale data after a later total outage could still render its old partial-data warning while the current request was loading or failed. That made historical transport truth look current.
Fix: the lane now carries an explicit freshness flag. Partial warnings render only for a fresh successful response; stale content remains visible under the top-level loading/error state.
Tests: CompanyHubHomeScreenModelTest preserves the partial snapshot across total outage and
MobileUxInvariantsTest protects the stale-banner boundary. Runtime proof remains separately open.
2026-07-16 — Current Android proof path covers populated Hub docs state
Type: Documentation and proof update
Summary:
- Added
.maestro/critical/63_hub_docs_populated.yaml, following the current Workspace → Hub → Docs route and asserting both seeded published document titles. - Recorded the prior red baseline: the older flow targeted the retired top-level Hub selector and failed on current main before reaching the feature.
- This is emulator UI evidence only. Named Android/iPhone runtime evidence and PM-owned audit scoring remain open; no product behavior or audit score is changed.
Tests: guarded :app:assembleDevDebug plus one Pixel 7 API 35 emulator run of the new
Maestro flow; retain the commit-bound evidence path in the PR.
2026-07-15 — Publishing a document updates the open version immediately
Type: Bug fix (frontend cache coherence)
Summary:
- Routed
DocViewerpublishing through the canonical TanStack Query mutation so Hub document lists are invalidated after a successful publish. - Replaced
DocsTab’s selected document with the server response immediately. The version badge, draft state, publish action, and subsequent edit now use the published record without a manual refresh. - Added a component regression proving that the publish response is handed back to the owning tab.
2026-07-14 — Contract Graph ownership and proof alignment
Type: Documentation and proof-traceability change
Summary:
- Added the canonical
contracts/features/F28.cuecontract for the 19 announcement and document endpoints, digest workflow,/hubroute, dependencies, and explicit runtime gaps. - Attached stable proof IDs to existing Hub pytest and red-team tests covering tenant isolation, authorization, acknowledgement, versions, history, attachments, empty content, and digest scheduling.
- Added the current implementation audit and linked the contract from the feature registry.
- Kept live Telegram delivery and browser/native runtime evidence blocked until those environments produce commit-bound results.
2026-07-12 — Mobile Hub document deletion is complete and role-true
Type: Mobile CRUD completion (Android + iOS KMP)
Summary:
- Owner and admin users can now delete a Hub document from the shared edit screen through a visible localized action and one destructive-action confirmation. Members and managers do not receive the affordance, matching the backend
IsOrgOwnerOrAdminrule. - The edit model requires the displayed confirmation and makes save/delete mutually exclusive, retains the screen with a localized generic failure on error, and invokes the platform callback only after a successful
DELETE /api/hub-docs/<id>/. - Android reloads its retained Hub-doc model before navigating back; iOS recreates its retained Hub-doc list controller after delete. Both return to a list that excludes the deleted document.
Tests: EditHubDocScreenModelTest covers owner/admin success, owner/admin failure, member/manager gating, confirmation enforcement, save/delete exclusion, role normalization, and duplicate-delete prevention; the full shared JVM suite and platform-parity gate remain required.
2026-07-02 — Announcements wired to real F81 universal comments + per-announcement deep-link
Type: Bug Fix / Feature
Problem: The “Discuss” button on AnnouncementCard faked discussion by navigating to
/messages?topic=<title> — this opened the message-channel view by title-search, not
a real comment thread, and was entirely disconnected from the F81 universal comment system.
Fix:
- Backend (
apps/comments/views.py):POST /api/comments/withtarget_type="announcement"already accepted the type (it was inComment.TARGET_CHOICES) but never validated that the targetAnnouncementexisted in the caller’s org. Added a tenant-scoped existence check: ifAnnouncement.objects.filter(id=target_id, organization_id=request.organization_id).exists()is False → 404. This closes a ghost-comment vector (comment attached to a non-existent or cross-tenant announcement). - Frontend (
AnnouncementCard.tsx): Removednavigate('/messages?topic=…'). The “Discuss” button now toggles a slide-outCommentThreadpanel (target_type="announcement",target_id={announcement.id}) — the exact same UI pattern DocViewer uses for docs. Added “Copy link” button writingorigin/hub?announcement=<id>to clipboard. - Frontend (
AnnouncementsTab.tsx): AcceptsdeepLinkAnnouncementIdanddeepLinkCommentIdprops; passesopenComments+focusCommentIdto the matchingAnnouncementCard. - Frontend (
HubPage.tsx): Reads?announcement=<id>&comment=<cid>from URL and passes them toAnnouncementsTab. The backendcomment_deeplinkfor announcements was already/hub?announcement={id}&comment={id}(set in F81services.py) — this wires the frontend to honour exactly that format. - i18n: Added
copy_link,copy_link_success,copy_link_errorto all 62 localehub.jsonfiles (vi translated; others use English placeholder for parity). - Tests:
backend/tests/test_comments.py— newTestAnnouncementTargetclass: create-on-real-announcement (201), nonexistent-announcement (404), cross-tenant-announcement (404), list-scoped-to-tenant (200 empty for other org).
Deep-link format: /hub?announcement=<id>&comment=<comment-id> (matches comment_deeplink
in apps/comments/services.py).
2026-07-02 — Markdown rendering for announcement bodies on mobile (KMP)
Type: Enhancement (mobile)
Summary: The shared Compose (KMP) mobile shell now renders announcement bodies as
formatted markdown. The announcement detail body (AnnouncementDetailScreen.kt) and the
hub list body preview (AnnouncementCard in HubScreen.kt) now use the shared
LumiMarkdownText component (com.locuno.lumi.shared.ui, wrapping
com.mikepenz:multiplatform-markdown-renderer-m3:0.43.0, Lumi-token themed) instead of a
plain Text. Renders bold/italic, headings, lists, links, inline code, code blocks,
blockquotes, and horizontal rules; blank input renders nothing; plain text renders as a
paragraph. The same component was adopted across comments, messages, chat, and the brief
so every long-form surface shares one renderer.
2026-07-02 — TipTap WYSIWYG markdown editor + inline image upload + attachment UI
Type: Feature (frontend)
Summary: Replaced the <Textarea> + markdown-preview toggle in ComposeAnnouncement and DocEditor with a full TipTap WYSIWYG editor that reads/writes body_md (markdown stored, never HTML). Implemented inline image upload and the attachment compose/view pair that completes the backend-only F34 attachment contract shipped in the same changelog entry below.
Changes:
frontend/src/lib/upload.ts: NewuploadFile(file)async utility extracted fromuseUploadhook — eliminates duplicate presign→S3→complete logic. BothAttachmentListand the editor’s image upload call this single helper.frontend/src/lib/markdownSerializer.ts: Custom TipTap JSON → Markdown serializer (ProseMirror doc nodes → Markdown string). Handles heading, paragraph, bulletList, orderedList, listItem, blockquote, codeBlock, horizontalRule, hardBreak, image, and all marks (bold, italic, strike, code, link).frontend/src/components/editor/MarkdownEditor.tsx: Shared WYSIWYG component (value: string,onChange: (md) => void). Load:markedconvertsbody_md→ HTML → TipTap. Save:editor.getJSON()→tipTapJsonToMarkdown()→onChange. Inline Image node (custom,Node.create()) renders<img>in the editor and serialises to. Paste/drop handled via ProseMirror plugin withuploadFile(). Toolbar: Bold, Italic, H1, H2, BulletList, OrderedList, Code, CodeBlock, Link, Image.frontend/src/components/editor/AttachmentList.tsx: Compose-time file picker. CallsuploadFile()per file, shows progress, accumulatesuploadIdsviaonChange.frontend/src/components/editor/AttachmentsRow.tsx: View-time read-only list renderingHubAttachment[]as downloadable links with file size.frontend/src/types/index.ts: AddedHubAttachmentinterface; addedattachments?: HubAttachment[]toAnnouncementandDoc.frontend/src/pages/hub/ComposeAnnouncement.tsx: Replaced<Textarea>+ preview with<MarkdownEditor>+<AttachmentList>.frontend/src/pages/hub/DocEditor.tsx: Same replacement.frontend/src/pages/hub/AnnouncementCard.tsx: Renders<AttachmentsRow>whenannouncement.attachmentsis non-empty.frontend/src/pages/hub/DocViewer.tsx: Renders<AttachmentsRow>whendoc.attachmentsis non-empty.- i18n: Added 17 new keys to
hubnamespace (toolbar labels, attachment flow, errors) — translated invi, English placeholder in all other 60 locale files. i18n.test.ts key-parity maintained. - Tests:
frontend/src/test/MarkdownEditor.test.tsx— 16 tests: 10 pure-function serialiser round-trips + 6 component smoke tests (TipTap editor renders,attachment_idsincluded in API calls). 0 TypeScript errors. - Symlinks fixed:
frontend/node_modules/@tiptap/pmwas pointing to non-existentlumi-fleet-A; corrected to the pnpm virtual store.@tiptap/coreadded as a symlink (was transitive dep only) and declared as a direct dep inpackage.json.
2026-07-02 — F28 file attachments via F34 Upload (no new upload endpoints)
Type: Feature (backend-only; frontend UI in follow-up PR)
Summary: Announcements and hub docs can now carry file attachments by reusing the existing F34 presign→S3→complete upload infrastructure with zero new upload endpoints.
Changes:
backend/apps/hub/models.py: Addedattachments = ManyToManyField("uploads.Upload", blank=True, related_name="+")to bothAnnouncementandDoc.backend/apps/hub/serializers.py: AddedUploadRefSerializer(id, filename, content_type, byte_size, url=public_url — mirrors F81 commentupload_*field shape); addedattachmentsread field andattachment_idswrite field toAnnouncementSerializerandDocSerializer;validate_attachment_idsenforces tenant isolation (400 on cross-tenant or nonexistent IDs);create/updateoverrides handle M2M; PATCH withoutattachment_idsleaves M2M unchanged.backend/apps/hub/views.py: Added"attachments"toprefetch_relatedin bothAnnouncementViewSetandDocViewSetquerysets to prevent N+1 on list.- Migration:
0004_announcement_doc_attachments_m2m.py. - Tests (
backend/tests/test_hub.py):TestAnnouncementAttachments(8 cases) andTestDocAttachments(6 cases): create with attachments, create without, update add/remove, PATCH withoutattachment_idsleaves existing, cross-tenant → 400, nonexistent → 400, multiple attachments. N+1 list-query bound bumped from ≤5 to ≤6 (one extra prefetch query).
Frontend contract (for follow-up PR):
- Read:
attachments: [{id, filename, content_type, byte_size, url}]on every announcement/doc response. - Write:
attachment_ids: [uuid, ...]onPOST /api/announcements/,PATCH /api/announcements/<id>/,POST /api/hub-docs/,PATCH /api/hub-docs/<id>/. Client presigns + completes each file via the existingPOST /api/uploads/presign+POST /api/uploads/<id>/completeflow, then passes the resulting Upload UUIDs asattachment_ids. - Inline images: embed
Upload.public_url(returned from the complete response) directly inbody_md. No new serving route needed.
2026-06-19 — Announcement edit + delete
Type: Feature (CRUD completion)
Summary: Announcements could be created + published/acked but not edited or deleted. Added useUpdateAnnouncement/useDeleteAnnouncement and, on AnnouncementCard, an author/manager-gated Edit dialog (title + body_md) and confirmation Delete (matching existing publish/stats gating). Deletes are audit-logged (F16). i18n keys added to en/vi (hub).
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Numbered “Current Behavior” as §8 (was unnumbered between §7 and old §8).
- §8 Diagrams → §10; §9 Implementation Audit (narrative) → §11 (16-row table); §12 Edge Cases absorbed into §11 gaps; §13 Error States → §9 (table format); §14 Bug/Change Log → §13; §11 Open Questions (was at EOF) → §12.
- §8 Current Behavior expanded with PR-by-PR build history.
- §11 audit: 16 rows, 12 ✅ match, 4 ⚠️ gaps (pin limit, blank body validation, delete-while-pinned, Telegram mass-publish truncation).
2026-06-18 — Visual QC: give /hub a real operating shell and add screenshot coverage
Type: UX Fix
Summary:
- The main
/hubroute was functionally correct but visually underpowered: just a title and a plain tab bar above rich announcement/doc content, which made the first viewport feel unfinished compared with the rest of the workspace. HubPage.tsxnow opens with a compact operating summary, live announcement/doc counts, a visible access-state hint, and calmer tab buttons that explain the difference between announcements and docs before the user drills in.- Screenshot flow now captures the Hub lane directly in
frontend/screenshots/16-hub/(hub-announcements,hub-docs,hub-doc-search) so future CRM/Hub QC can compare the actual page instead of relying on chat-only coverage.
Verification:
frontend/src/test/HubPage.test.tsxfrontend/screenshots/16-hub/01-hub-announcements.pngfrontend/screenshots/16-hub/02-hub-docs.png
2026-06-19 — Messages visual QC: default into the real channel and extend screenshot coverage
Type: UX Fix + Documentation Change
Summary:
- Added the missing Messages screenshot set at
frontend/screenshots/20-messages/(01-messages-inbox.png,02-messages-new-channel.png,03-messages-new-dm.png) so the Hub messaging surface is now covered by the visual audit flow. - Fixed the initial
/hub/messagesstate so when the org already has channels but no thread is preselected, the page auto-selects the default channel (or the first available channel, then the first DM if channels are absent) instead of showing an inactive-looking empty thread pane beside a populated sidebar. - Added a focused regression test for that auto-selection behavior in
frontend/src/test/MessagesPage.test.tsx. - Updated the screenshot flow to capture both creation affordances directly from the live page: the new-channel modal and the new-DM modal.
Reason:
- The pre-pass screenshot showed the core quality issue immediately: a visible
#chungchannel in the sidebar, but an apparently blank thread area on first load. That made the product feel unfinished even though data existed.
Impact:
/hub/messagesnow opens into a conversation-first state that feels live and intentional.- The screenshot suite now proves the inbox, channel creation, and DM creation states instead of covering only the generic
/hublanding route.
Verification:
cd frontend && pnpm test -- src/test/MessagesPage.test.tsxcd frontend && pnpm exec tsc --noEmitcd frontend && pnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "20-messages"
2026-06-13 — Doc version history pagination (C-r24)
Type: Bug Fix
Summary:
DocViewSet.history()now returns a paginated dict response (results,count,next,previous) instead of a flat list.- Uses DRF’s global
PageNumberPagination(page_size=50). - 2 xfail(
strict=True) tests intest_red_r20_f.pynow pass —@pytest.mark.xfailmarkers removed.
Reason:
- Doc history endpoint grew unbounded. The
GET /api/hub-docs/<id>/history/response was a flat list of all versions — unsustainable as version count grows.
Tests:
test_red_r20_f.py::TestF28DocHistoryPagination::test_history_returns_paginated_responsetest_red_r20_f.py::TestF28DocHistoryPagination::test_history_second_page_returns_results
2026-06-12 — Added Edge Cases + Error States sections
Type: Documentation Change
Summary:
- Added §12 Edge Cases (E1–E9): pin limit (no hard cap), unpin last pinned, empty body validation, attachment size (out of scope), empty hub state, delete-while-pinned, empty doc body, ack on deleted announcement, mass-publish digest length.
- Added §13 Error States (ES1–ES8): cross-tenant access, cross-tenant ack, member creates announcement, non-admin creates doc, draft visibility, empty search, ack stats on deleted, ack stats for non-author.
2026-06-12 — Server-side pinned filter on announcements (A-r07)
Type: Bug Fix
Summary:
AnnouncementViewSet.get_queryset()now supportspinned=trueandpinned=falsequery params.- Frontend
TodayPagealready expected?pinned=truebut backend previously ignored the param.
Tests:
test_hub.py::TestAnnouncement::test_pinned_true_filtertest_hub.py::TestAnnouncement::test_pinned_true_false_filter
2026-07-10 — Hub doc versioning without vanishing-doc regression (feedback #55)
Type: Bug Fix
Problem: PATCHing a published Doc flipped the Doc to draft, making it vanish from member-facing list/detail/search until re-publish (53e4e04). The org’s official doc disappeared — violated F28 “exactly one current truth”.
Root cause: DocViewSet.search() filtered on the Doc’s live title/body_md fields;
after PATCH flipped status to draft, those fields held draft content. The member
list/detail views worked because DocSerializer.to_representation() swapped in published
values from _published_* annotations, but the search filter operated pre-serialization
on the live fields, so a search for old published keywords missed the doc entirely.
Fix:
backend/apps/hub/views.pyDocViewSet.search(): for non-admin members, the search filter now uses anExistssubquery againstDocVersion.title/body_mdinstead of the Doc’s own fields. This means a doc is findable by its published content even while the admin holds a draft edit.- Existing behavior unchanged for admins (search Doc’s own fields) and for Postgres SearchVector path (same Exists pattern applied).
Tests:
TestDocVersioning::test_draft_content_never_in_member_payload— passes now (was failing: search returned 0 results instead of 1)- All 7 new
TestDocVersioningtests pass: edit resets to draft but member still sees snapshot, draft content never leaks, admin sees draft directly, version history intact, cross-tenant isolation, search shows published snapshot, edit-draft-doc stays draft.
2026-07-12 — Mobile announcement delete capability parity
Type: Bug Fix · Mobile parity
Problem: The shared announcement detail screen used preset role names for edit/delete visibility,
while the backend IsOrgManager permission resolves the effective members.manage capability.
Custom roles with that capability were hidden, and confirmDelete() did not independently enforce
authorization or an open confirmation dialog.
Fix:
AnnouncementDetailRepositorynow loads effective capabilities from/api/permissions/me/.AnnouncementDetailScreenModelgates affordances and confirmation onmembers.manage, fails closed on lookup failure, and revalidates on refresh.- Regression tests cover preset/custom capability paths, missing/failed lookup, confirmation bypass, and duplicate delete calls.
2026-07-15 — Native acknowledgement read-count visibility (HUB-03)
The shared Hub announcement detail now consumes the existing tenant-scoped acknowledgement
stats endpoint only when the server marks the current member as an authorized author or manager.
Members do not probe the forbidden endpoint. The native surface renders localized read by N of M
copy and keeps the announcement visible when stats loading fails. Shared unit proof covers the
authorized, member-denied, and stats-error paths; named Android floor-device and iPhone 12 proof
remains an explicit Contract Graph gap.
2026-07-16 — Android announcement publish round-trip (FRM-15)
The Android Hub authoring route now exposes locale-independent selectors for the manager-gated create action, title/body fields, audience and pinned controls, Publish, and the detail delete confirmation. Create and delete callbacks refresh the remembered Hub model before returning, so a successfully published record appears without a process restart and a deleted record does not remain as a stale row.
.maestro/critical/69_announcement_publish_roundtrip.yaml owns the repeatable Android
journey: Workspace → Hub → author with the keyboard open → publish → read → confirmed
delete. The flow removes only its uniquely named fixture through the product UI. Emulator
output is UI-only; same-day named Android floor-device and iPhone 12 evidence remains a
PM-owned Contract Graph gap, and no audit score follows from source or emulator proof.
The first emulator run exposed stale mobile PATCH/DELETE paths under /api/hub/announcements/;
the backend has always mounted those mutations at /api/announcements/. Both client paths now
use the canonical route; a Ktor request test executes PATCH and DELETE against that URL, while a
source invariant rejects the retired prefix. The corrected journey
passed 1/1 in 53 seconds on the sole Lumi_Pixel7_API35 emulator with a matching built/installed
APK SHA-256 of b06e2ec0641764c16aabbe73775fc7e98d03263cf0cd65ccf4ebbce862d9a013.
Durable local evidence is under
/Users/james/.maestro/tests/20260716T201947_frm15_announcement_publish.