shipped (2026-06-11)

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)

MethodPathMin roleNotes
CRUD/api/announcements/manager (write) / member (read)List respects audience; ?unread=me
POST/api/announcements/<id>/ack/memberIdempotent
GET/api/announcements/<id>/acks/author/adminRead stats
CRUD/api/hub-docs/admin (write) / member (read)Publish bumps version, snapshots DocVersion
GET/api/hub-docs/search/?q=memberTitle+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’s swagger-ui route 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: hub namespace, en+vi (all 62 locales in parity via i18n.test.ts).
  • WYSIWYG editor (2026-07-02): ComposeAnnouncement and DocEditor now use <MarkdownEditor> (frontend/src/components/editor/MarkdownEditor.tsx) — a TipTap WYSIWYG that reads/writes body_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 in body_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 from useUpload, no duplication). Image resolves to public_url and is inserted as a native image node in the editor (renders visually) and serialised to ![alt](url) in body_md.
  • File attachments — compose (2026-07-02): <AttachmentList> (frontend/src/components/editor/AttachmentList.tsx) below the editor in both compose surfaces. Uploads each file via uploadFile(), accumulates attachment_ids passed to POST /api/announcements/ and POST/PATCH /api/hub-docs/.
  • File attachments — view (2026-07-02): <AttachmentsRow> (frontend/src/components/editor/AttachmentsRow.tsx) renders attachments[] from the API response as downloadable links in AnnouncementCard and DocViewer.
  • 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

#CaseTypeWhere
1Announcement lifecycle, audience filtering (team-scoped invisible to others), cross-tenantbackendnew classes in backend/tests/
2Ack idempotency; stats visibility limited to author/adminbackendsame
3Doc versioning: publish snapshots, latest-wins, history readablebackendsame
4Search returns published onlybackendsame
5Post → ack → stats flow; doc reade2enew describe("Hub")
6Native Company Hub partial response preserves loaded counts, marks unavailable sections, and retriesshared KMP unit + structural UICompanyHubHomeRepositoryTest; MobileUxInvariantsTest
7Native Hub Documents renders one header when embedded and retains its header when routed standaloneshared KMP structural UIMobileUxInvariantsTest.hubDocumentsUsesExactlyOneOwningHeaderPerPresentationContext

8. Current Behavior

Shipped across P06 PR-1..PR-3 (2026-06-11).

  • PR-1 (backend models + announcements API): Announcement, Acknowledgement models; /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/DocVersion models; /api/hub-docs/ CRUD with publish/history/search actions. Publish bumps integer version and snapshots body into DocVersion. Evening digest (apps.hub.tasks.send_hub_digest) via F21 Telegram plumbing — one bundled message per org per evening at DIGEST_HOUR=18, timezone-aware, checked every 15 min via CELERY_BEAT_SCHEDULE["hub-evening-digest"]. Doc version history pagination added (C-r24 bug fix — see §13).
  • PR-3 (frontend): /hub route — HubPage (tabs: announcements/docs), AnnouncementsTab + ComposeAnnouncement + AnnouncementCard (publish, ack, read-stats for managers), DocsTab + DocEditor + DocViewer (publish, history, search). Shared Markdown component (frontend/src/components/markdown/Markdown.tsx, marked + dompurify) renders all body markdown — script/HTML injection neutralized. TodayPage shows pinned-announcement banner (“Xem trong Hub” CTA) until acked, max 1. hub/sidebar i18n namespaces complete (en+vi). Manager/admin role detection fixed in Layout.tsx — loadOrganizations() now runs alongside loadOrganization() on session bootstrap so useAuthStore().organizations is populated after page reload. 6 E2E tests in describe("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): HubScreen embeds HubDocsScreen with the child page header disabled, so the parent Hub title and segmented tabs remain the only page chrome. Direct HubDocsScreen callers keep the default standalone title and back action.

9. Error States

#ErrorUser messageHTTPLoggedNotes
ES1Cross-tenant announcement access — member of org A tries to access org B’s announcement UUID.Standard 404404NoAnnouncementViewSet queryset filters by request.tenant.
ES2Cross-tenant ack — member of org A tries to ack an announcement from org B.Standard 404404NoSame queryset scoping.
ES3Member creates announcement — member (not manager/admin) calls POST /api/announcements/.No announcement created403NoGated by IsOrgManager permission class.
ES4Non-admin creates doc — manager (not admin) calls POST /api/hub-docs/.No doc created403NoGated by IsOrgOwnerOrAdmin permission class.
ES5Member 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)NoTestAnnouncement::test_draft_invisible_to_members.
ES6Search 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)NoTestHubSearch::test_search_empty_query_returns_published_docs.
ES7Ack stats for deleted announcement — GET /api/announcements/<uuid>/acks/ on a deleted announcement.Standard 404404Noget_object_or_404 behavior.
ES8Ack stats for non-author member — a member who is not the announcement author calls the stats endpoint.announcements.error_stats_forbidden403NoTestAcknowledgement::test_stats_endpoint_forbidden_for_non_author_member.
ES9Native 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/ANon-transient failuresCompanyHubSummary.unavailableSections preserves failure provenance; no failed section is presented as a confirmed zero.
ES10Native 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/ANon-transient failuresCompanyHubHomeScreenModel 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.

#ClaimStatusEvidence
1Announcement 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
2Acknowledgement model: announcement FK, member FK, ack_at; unique(announcement, member)✅ match§4; TestAcknowledgement::test_ack_idempotent verifies idempotency
3Doc/DocVersion models: publish bumps integer version + snapshots DocVersion✅ matchP06 PR-2; TestDoc::test_publish_bumps_version_and_snapshots
4All 5 endpoints: announcements CRUD, /ack/, /acks/, hub-docs CRUD, hub-docs/search — tenant-scoped✅ match§5 API contract; cross-tenant tests at TestAnnouncement + TestHubSearch
5Telegram evening digest (send_hub_digest) via F21 plumbing; DIGEST_HOUR=18; per-org timezone-aware✅ matchP06 PR-2; apps.hub.tasks.send_hub_digest; TestHubDigest::test_digest_bundles_published_announcements_per_member
6Markdown sanitizer — Markdown.tsx (marked + dompurify); never dangerouslySetInnerHTML raw✅ matchP06 PR-3; frontend/src/components/markdown/Markdown.tsx
7/hub route: HubPage (announcement + doc tabs), pinned-first ordering✅ matchP06 PR-3; 6 E2E tests in describe("Hub"); TestAnnouncement::test_pinned_ordering
8TodayPage pinned banner (max 1 pinned announcement, “Xem trong Hub” CTA) until acked✅ match§2 AC; P06 PR-3; TodayPage.tsx; banner disappears after ack
9hub/sidebar i18n namespaces (en+vi) complete✅ match§6; P06 PR-3; no hardcoded strings rule satisfied
10Manager/admin role detection fix in Layout.tsx — loadOrganizations() on session bootstrap✅ matchP06 PR-3; fixes compose/publish UI after page reload
11Doc version history pagination — /api/hub-docs/<id>/history/ returns paginated dict✅ matchC-r24 bug fix; TestF28DocHistoryPagination::test_history_returns_paginated_response; test_history_second_page_returns_results
12Pinned filter ?pinned=true/false on announcements list✅ matchA-r07 bug fix; TestAnnouncement::test_pinned_true_filter; test_pinned_true_false_filter
13Pin limit — no hard max on Announcement.pinned; API does not enforce a count limit⚠️ gaptest_pinned_ordering tests order, not count; admin UI should warn but no middleware or model validation exists
14Blank body_md validation returns 400⚠️ gapSerializer-level enforcement stated but no explicit test for blank announcement or doc body
15Delete-while-pinned — Acknowledgement cascade removes; TodayPage banner disappears⚠️ gapStandard CASCADE behavior; no explicit test for pinned-announcement deletion + banner state
16Telegram digest truncation on mass publish — 4096-char Telegram limit handled gracefully⚠️ gapTestHubDigest 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_time previously used Organization.objects.iterator() while each organization could perform Telegram and database work. On the dev worker this produced a PostgreSQL cursor "_django_curs_..." already exists retry 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-worker logged one retry at the 2026-08-15 17:15 container-local tick; the task’s autoretry_for kept 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 DocViewer publishing 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.cue contract for the 19 announcement and document endpoints, digest workflow, /hub route, 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 IsOrgOwnerOrAdmin rule.
  • 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.

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/ with target_type="announcement" already accepted the type (it was in Comment.TARGET_CHOICES) but never validated that the target Announcement existed in the caller’s org. Added a tenant-scoped existence check: if Announcement.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): Removed navigate('/messages?topic=…'). The “Discuss” button now toggles a slide-out CommentThread panel (target_type="announcement", target_id={announcement.id}) — the exact same UI pattern DocViewer uses for docs. Added “Copy link” button writing origin/hub?announcement=<id> to clipboard.
  • Frontend (AnnouncementsTab.tsx): Accepts deepLinkAnnouncementId and deepLinkCommentId props; passes openComments + focusCommentId to the matching AnnouncementCard.
  • Frontend (HubPage.tsx): Reads ?announcement=<id>&comment=<cid> from URL and passes them to AnnouncementsTab. The backend comment_deeplink for announcements was already /hub?announcement={id}&comment={id} (set in F81 services.py) — this wires the frontend to honour exactly that format.
  • i18n: Added copy_link, copy_link_success, copy_link_error to all 62 locale hub.json files (vi translated; others use English placeholder for parity).
  • Tests: backend/tests/test_comments.py — new TestAnnouncementTarget class: 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: New uploadFile(file) async utility extracted from useUpload hook — eliminates duplicate presign→S3→complete logic. Both AttachmentList and 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: marked converts body_md → HTML → TipTap. Save: editor.getJSON() → tipTapJsonToMarkdown() → onChange. Inline Image node (custom, Node.create()) renders <img> in the editor and serialises to ![alt](url). Paste/drop handled via ProseMirror plugin with uploadFile(). Toolbar: Bold, Italic, H1, H2, BulletList, OrderedList, Code, CodeBlock, Link, Image.
  • frontend/src/components/editor/AttachmentList.tsx: Compose-time file picker. Calls uploadFile() per file, shows progress, accumulates uploadIds via onChange.
  • frontend/src/components/editor/AttachmentsRow.tsx: View-time read-only list rendering HubAttachment[] as downloadable links with file size.
  • frontend/src/types/index.ts: Added HubAttachment interface; added attachments?: HubAttachment[] to Announcement and Doc.
  • 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> when announcement.attachments is non-empty.
  • frontend/src/pages/hub/DocViewer.tsx: Renders <AttachmentsRow> when doc.attachments is non-empty.
  • i18n: Added 17 new keys to hub namespace (toolbar labels, attachment flow, errors) — translated in vi, 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_ids included in API calls). 0 TypeScript errors.
  • Symlinks fixed: frontend/node_modules/@tiptap/pm was pointing to non-existent lumi-fleet-A; corrected to the pnpm virtual store. @tiptap/core added as a symlink (was transitive dep only) and declared as a direct dep in package.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: Added attachments = ManyToManyField("uploads.Upload", blank=True, related_name="+") to both Announcement and Doc.
  • backend/apps/hub/serializers.py: Added UploadRefSerializer (id, filename, content_type, byte_size, url=public_url — mirrors F81 comment upload_* field shape); added attachments read field and attachment_ids write field to AnnouncementSerializer and DocSerializer; validate_attachment_ids enforces tenant isolation (400 on cross-tenant or nonexistent IDs); create/update overrides handle M2M; PATCH without attachment_ids leaves M2M unchanged.
  • backend/apps/hub/views.py: Added "attachments" to prefetch_related in both AnnouncementViewSet and DocViewSet querysets to prevent N+1 on list.
  • Migration: 0004_announcement_doc_attachments_m2m.py.
  • Tests (backend/tests/test_hub.py): TestAnnouncementAttachments (8 cases) and TestDocAttachments (6 cases): create with attachments, create without, update add/remove, PATCH without attachment_ids leaves 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, ...] on POST /api/announcements/, PATCH /api/announcements/<id>/, POST /api/hub-docs/, PATCH /api/hub-docs/<id>/. Client presigns + completes each file via the existing POST /api/uploads/presign + POST /api/uploads/<id>/complete flow, then passes the resulting Upload UUIDs as attachment_ids.
  • Inline images: embed Upload.public_url (returned from the complete response) directly in body_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 /hub route 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.tsx now 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.tsx
  • frontend/screenshots/16-hub/01-hub-announcements.png
  • frontend/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/messages state 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 #chung channel in the sidebar, but an apparently blank thread area on first load. That made the product feel unfinished even though data existed.

Impact:

  • /hub/messages now 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 /hub landing route.

Verification:

  • cd frontend && pnpm test -- src/test/MessagesPage.test.tsx
  • cd frontend && pnpm exec tsc --noEmit
  • cd 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 in test_red_r20_f.py now pass — @pytest.mark.xfail markers 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_response
  • test_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 supports pinned=true and pinned=false query params.
  • Frontend TodayPage already expected ?pinned=true but backend previously ignored the param.

Tests:

  • test_hub.py::TestAnnouncement::test_pinned_true_filter
  • test_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.py DocViewSet.search(): for non-admin members, the search filter now uses an Exists subquery against DocVersion.title/body_md instead 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 TestDocVersioning tests 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:

  • AnnouncementDetailRepository now loads effective capabilities from /api/permissions/me/.
  • AnnouncementDetailScreenModel gates affordances and confirmation on members.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.