shipped (backend + frontend; E2E written, pending live-stack run)

Spec: F81 — Universal Comment System

Status: shipped (backend + frontend; E2E written, pending live-stack run)

1. Problem & CEO Job

Serves trust the signal and decide fast. Commenting is fragmented: Tasks use TaskComment, Deals use DealComment, and Hub docs have none — DocViewer has no comment UI and Doc has no comment model or route. Each discussion surface re-implements persistence, APIs, notifications, and its React panel, so a CEO must leave a policy doc, brief, or risk for chat and lose the decision’s context. F81 provides one system for every supported object (doc, task, deal, brief, decision, risk, commitment, announcement, Pulse), with Notion-style document anchors, notifications, and shareable links that reopen the focused comment.

2. User Stories & Acceptance Criteria

  • As a member, I want to comment on a document, so that I can ask a question where the content is.
    • Given an open doc, when I post a comment, then it appears in the doc’s comment panel and is visible to other org members on that doc.
    • Given a doc in another org, when I try to read or post its comments, then I get 404/403 (tenant isolation).
  • As a member, I want to select text in a doc and attach an inline comment, so that the discussion is anchored to the exact passage (like Notion/Google Docs).
    • Given I select a passage and comment, then the comment stores a text anchor and the passage is visually highlighted; clicking the highlight opens that comment thread.
    • Given the doc text later changes, when the anchored quote can no longer be found, then the comment is shown as “orphaned”/general (never lost, never crashes).
  • As any participant, I want to reply to a comment and resolve a thread, so that finished discussions collapse.
    • Given a root comment, when I reply, then the reply nests under it ordered by time.
    • Given a thread, when I resolve it, then it is marked resolved with who/when and hidden from the default (unresolved) view but still reachable.
  • As a mentioned user, I want a notification with a direct link, so that I can jump straight to the comment.
    • Given a comment body contains @name, when posted, then that member gets an in-app NotificationEvent (category mention) whose route deep-links to the object with ?comment=<id>, and a push if their push_mentions pref is on.
    • Given I am a prior participant in a thread (author or replier) and not the commenter, when a new reply is posted, then I get a mention-style notification (thread follow) unless I muted it.
    • Given I open a ?comment=<id> link, then the object opens, the comment scrolls into view and is briefly highlighted.
  • As the comment author, I want to edit or delete my own comment.
    • Given my own comment, when I edit it, then edited_at is set and the new body shows.
    • Given my own comment, when I delete it, then it is soft-deleted (body replaced with a tombstone) so replies keep their context; others’ comments I cannot edit/delete (403) unless I have the effective members.manage capability.

3. Scope Boundaries

  • In: one comments Django app with a soft-polymorphic Comment model (target_type, target_id); list/create/edit/delete/resolve/reply API scoped per target; @mention + thread-follow notifications with deep links; a reusable React CommentThread + useComments hooks; doc-panel comments and doc inline comments wired into DocViewer; partition-ready schema (see §4).
  • Out (deliberately, with homes):
    • Migrating existing TaskComment / DealComment to the new table — not in this spec. F81 is additive; new surfaces use Comment. A later migration spec can backfill. (Avoids a risky data-move on shipped features mid-feature.)
    • Rich text inside comments (markdown body only). A single file attachment per comment IS supported (Comment.upload FK → presigned S3 upload), and the composer offers a basic emoji picker (inserts unicode into the body — not stored reactions).
    • Real-time websocket push of new comments — polling cadence reuses the F77 pattern (60s) + refetch on focus. WebSockets deferred (same call as F29).
    • Stored emoji reactions on a comment (a reaction table) and comment search in global search (F80) — future. (Inline emoji in the body is supported; reactions are not.)
    • Inline comments on surfaces other than Hub docs (tasks/deals get the panel only for now).

4. Data Model

New app apps/comments, one model Comment (extends UUIDModel → uuid7 PK, created_at/updated_at).

class Comment(UUIDModel):
    TARGET_CHOICES = [
        ("doc", "Doc"), ("task", "Task"), ("deal", "Deal"),
        ("announcement", "Announcement"), ("brief", "Brief"),
        ("decision", "Decision"), ("risk", "Risk"), ("commitment", "Commitment"),
        ("pulse", "Pulse"),
    ]
    organization = FK(Organization, CASCADE, related_name="comments")          # tenant key — on every row
    target_type  = CharField(max_length=32, choices=TARGET_CHOICES)            # soft polymorphic ref…
    target_id    = UUIDField()                                                 # …no ContentType coupling
    parent       = UUIDField(null=True, blank=True)  # soft root id; tenant/target validated on write
    author       = FK(OrganizationMember, CASCADE, related_name="authored_comments")
    body         = TextField()
    anchor       = JSONField(default=dict, blank=True)   # inline anchor; {} = panel/top-level thread
    resolved_at  = DateTimeField(null=True, blank=True)
    resolved_by  = FK(OrganizationMember, null=True, blank=True, SET_NULL, related_name="+")
    edited_at    = DateTimeField(null=True, blank=True)
    deleted_at   = DateTimeField(null=True, blank=True)  # soft delete — preserves thread integrity

    class Meta:
        db_table = "comments"
        ordering = ["created_at"]
        indexes = [
            Index(fields=["organization", "target_type", "target_id", "created_at"]),  # list a target
            Index(fields=["organization", "parent"]),                                  # fetch replies
        ]

Inline anchor shape (W3C-style text selectors — robust on plain markdown, the no-regret choice; no fragile block IDs since docs are body_md plain text):

{ "quote": "exact selected text", "prefix": "≤32 chars before", "suffix": "≤32 chars after",
  "start": 1234, "end": 1290 }

Frontend re-anchors by searching quote (disambiguated by prefix/suffix); start/end are hints. Quote not found → comment renders as general (orphaned), never lost.

Thread model: a root comment has parent = null (and may carry an anchor). A reply sets parent to the root. resolved_* applies to roots. Replies inherit the root’s target.

Partition-readiness (explicit, MVP-simple, no-regret)

The table is designed so native Postgres partitioning is a mechanical later step, not a rewrite — per ENGINEERING.md → Scale-Ready Design (“make the future upgrade mechanical, don’t perform it now”). It mirrors F29 messaging and F77 NotificationEvent:

  1. uuid7 PK (time-sortable via core.models.uuid7) → keyset pagination + future range partitioning.
  2. organization_id on every row → the tenant hash-partition key; every query filters by it.
  3. Keyset pagination from day one: list by (created_at, id) watermark (?after=<id>), never OFFSET. (Notifications can adopt the same cursor later — mechanical.)
  4. No inbound FKs to comments from other tables; targets are referenced out by (type,id) soft ref — so a partition swap never breaks a foreign key.
  5. No global unique constraints or self-FKs (PK only). parent is a soft UUID root id validated inside (organization,target_type,target_id) by the write path.
  6. Composite indexes lead with organization_id so they remain prunable per partition.

We do not create partitions now (no premature optimization). Documented future path: hash-partition comments by organization_id (8–16 partitions) once a single tenant’s volume warrants it; the ORM/queries need no change because they already filter by organization_id and paginate by keyset.

Notification audit result: NotificationEvent already satisfies 1–6 above (uuid7 PK, org on every row, composite indexes leading with org+member, no inbound FKs, dedupe_key indexed-not-unique, bounded top-N reads). No migration needed. Only gap: it lacks a keyset “load more” cursor — out of scope here, mechanical to add later.

5. API Contract

Base: /api/comments/. Auth: IsAuthenticated, IsOrgMember. Every query filtered by organization=request.tenant; writes set organization + author from the request, never the client.

MethodPathAuth/RoleRequestResponseErrors
GET/api/comments/?target_type=doc&target_id=<uuid>&resolved=false&after=<id>org memberquery params{results:[Comment], next_after: id|null} keyset, page 50, replies embedded under roots400 missing target params
POST/api/comments/org member{target_type, target_id, body, parent?, anchor?}201 Comment400 invalid target/body, 404 parent in other tenant
PATCH/api/comments/<id>/author or members.manage{body}200 Comment (sets edited_at)403 not author/capable, 404 cross-tenant
DELETE/api/comments/<id>/author or members.manage—204 (soft delete: deleted_at, body tombstoned)403, 404
POST/api/comments/<id>/resolve/org member{resolved: bool}200 Comment (resolved_at/resolved_by set/cleared)400 if not a root, 404

Comment JSON: {id, target_type, target_id, parent, author, author_name, author_email, body, anchor, resolved_at, resolved_by, resolved_by_name, edited_at, is_deleted, created_at, reply_count, replies:[Comment]}.

Deep link: notifications carry route per target_type via a single comment_deeplink(target_type, target_id, comment_id) helper (e.g. doc → /hub?doc=<id>&comment=<cid>, task → /work/projects/<proj>/tasks?task=<id>&comment=<cid> when project known, else /work/tasks?...). No AI output → no EvidenceSource (n/a).

6. UI / UX

  • Routes/components:
    • frontend/src/components/comments/CommentThread.tsx — reusable panel: list roots (unresolved by default, toggle to show resolved), composer with @-mention autocomplete, reply, edit, delete, resolve. Driven by useComments(targetType, targetId).
    • frontend/src/hooks/queries/useComments.ts — TanStack Query list + create/edit/delete/resolve mutations with optimistic add; query key ['comments', targetType, targetId].
    • frontend/src/components/comments/InlineCommentLayer.tsx — for docs: wraps rendered markdown, captures text selection → “Comment” affordance, paints highlights for anchored comments, click → opens that thread in the panel.
    • Wire into frontend/src/pages/hub/DocViewer.tsx: a “Discuss”/comments toggle opening a right CommentThread for target_type="doc", plus the inline layer. Honor ?comment=<id> (open panel, scroll, highlight).
  • States: empty (“No comments yet — start the discussion”), loading (skeleton rows), error (retry), populated, no-permission (composer hidden for viewer role if applicable; read stays).
  • shadcn: Button, Textarea, Avatar, Badge, Popover (mention autocomplete + selection affordance), Tooltip, ScrollArea, Dialog (delete confirm) — all already in components/ui/.
  • i18n namespace comments (en + vi together). Keys: panel_title, composer_placeholder, post, reply, edit, save, cancel, delete, delete_confirm_title, delete_confirm_body, resolve, reopen, resolved_by, show_resolved, hide_resolved, empty_title, empty_body, orphaned_hint, mention_no_results, comment_on_selection, deleted_tombstone, error_load, error_post, success_resolved, reply_count.
  • DESIGN.md: use bg-card, text-foreground, text-muted-foreground, border-border, highlight uses bg-[var(--color-amber-soft)]; TASTE.md pre-flight applies (intentional hover/focus on highlights, hierarchy via author/time scale).

7. Test Plan

#CaseTypeWhere
1Create + list comments on a doc target (happy path)backendbackend/tests/test_comments.py::TestComment
2Cross-tenant isolation: org B cannot read/post/patch/delete org A’s commentsbackendbackend/tests/test_comments.py
3Reply nesting + reply_count; replies embedded under rootbackendsame
4Resolve/reopen sets/clears resolved_at/resolved_by; resolve on non-root → 400backendsame
5Edit by author sets edited_at; edit by non-author/non-admin → 403; owner/admin canbackendsame
6Soft delete tombstones body, keeps replies; only author or members.managebackendsame
13Mobile task/deal rows expose edit/delete to authors and members.manage, including custom rolesAndroid JVMTaskDetailScreenModelTest, DealDetailScreenModelTest
14Mobile capability lookup failure fails closed for non-authors while author ownership remains validAndroid JVMsame
7@mention creates NotificationEvent(category=mention) with ?comment= route; thread-follow notifies prior participants, not the commenterbackendbackend/tests/test_comments.py
8Keyset pagination: after=<id> returns next page, no overlapbackendsame
9Missing target_type/target_id → 400backendsame
10useComments + CommentThread render/post/reply/resolve (MSW)frontend unitfrontend/src/test/CommentThread.test.tsx
11Inline anchor: select text → comment → highlight; orphaned anchor renders generalfrontend unitfrontend/src/test/InlineCommentLayer.test.tsx
12Doc discuss flow: open doc, post comment, see it; ?comment= focuses ite2efrontend/tests/e2e/lumi.spec.ts describe("Document comments")

Backend coverage must stay ≥97% (apps/+core/); aim ≥99% on the new app. New E2E describe required.

8. Current Behavior

The universal comments backend and web document surfaces are shipped. Task and deal mobile detail screens consume the universal /api/comments/ endpoint, including author self-edit/delete and members.manage-gated non-author actions. DocViewer still owns the web document comment UI; the “Discuss” button exists only on AnnouncementCard and merely navigates to /messages?topic=<title> (no real thread linkage).

9. Error States

  • Load failure → panel shows error state with retry; toast.error(t('error_load')); query retries off for mutations, on for reads (TanStack default).
  • Post/edit/delete failure → optimistic update rolled back; toast.error(t('error_post')).
  • Cross-tenant / not-author-or-capability → backend 403/404; frontend surfaces generic error, never leaks existence.
  • Notification send failure is swallowed server-side (never breaks comment creation) and logged — mirrors _notify_comment_mentions in apps/work/views.py.
  • Orphaned inline anchor → comment shown as general with orphaned_hint; no crash.

10. Diagrams & Docs Touched

  • backend/DIAGRAMS.md — add comments app + Comment to the ER/app map; note soft-polymorphic ref.
  • frontend/DIAGRAMS.md — note CommentThread/InlineCommentLayer shared components; ?comment= deep-link handling in Hub route.
  • GLOSSARY.md — add Comment (universal), Inline comment / anchor, Thread / resolve; disambiguate from TaskComment/DealComment.
  • docs/MVP_SCOPE.md — new F81 row.
  • ADR — add ADR-019: Soft-polymorphic universal comments (target_type,target_id) over GenericForeignKey; partition-ready by organization_id. Records the no-regret DB decision.

11. Implementation Audit (2026-07-14)

The canonical Contract Graph declaration is contracts/features/F81.cue. The current code-vs-doc audit is F81-universal-comments-audit.md. The API-focused contract owns all five comment endpoints and collected backend proof; browser/native runtime and live notification delivery evidence remain explicit gaps.

Built (backend + frontend; E2E written, pending live-stack run).

Shipped and verified:

  • Backend apps/comments: Comment model + migration comments.0001_initial, keyset-paginated CommentViewSet (no DRF OFFSET), serializer, services.py (mention + thread-follow notifications via parse_mentions + send_push_to_member, comment_deeplink with ?comment=). Registered in INSTALLED_APPS + config/urls.py. 51 tests pass (count corrected 2026-07-02; the suite grew from the 34 recorded at the 2026-06-21 audit as visibility/upload/backfill/validation cases were added — see §13), new app 100% covered, suite ≥97%. Uses request.organization_id/request.membership (this repo’s IsOrgMember attrs; there is no request.tenant) — isolation enforced identically.
  • Frontend: commentsApi, useComments (optimistic + 60s poll), CommentThread, MentionComposer, InlineCommentLayer, lib/commentAnchor.ts; wired into DocViewer (Discuss toggle + inline layer + ?comment= focus/flash). i18n comments ns (en + real vi; English fallback copied to other locales per repo pipeline). 15 unit tests pass (count corrected 2026-07-02: CommentThread.test.tsx 12 + InlineCommentLayer.test.tsx 3), tsc clean, design-tokens guard clean.
  • Deep-link completion (added during integration): DocsTab now auto-opens the doc named by ?doc=, and HubPage opens the Docs tab when ?doc= is present — without these, a comment notification deep link landed on the Announcements tab and never opened the doc/comment.

Known deviations (accepted):

  • InlineCommentLayer surfaces anchored comments as clickable amber chips (orphaned = strikethrough) rather than painting in-text background spans — Range surgery over sanitized markdown HTML is fragile/a11y-risky. Anchor/orphan logic is fully implemented + unit-tested; upgradeable to span wrapping later without touching the pure anchor lib.
  • ScrollArea/Tooltip shadcn primitives don’t exist in components/ui/; used overflow-y-auto + title/aria instead (shadcn-only rule → use what exists).
  • E2E describe("Document comments (F81)") added to frontend/tests/e2e/lumi.spec.ts but not run here (no local backend on :8000; the dev frontend points at the shared dev API). Verify with make e2e before flipping the E2E registry cell to ✅.

Pre-existing/unrelated (not F81): test_multitenancy.py::TestInvitationLinks::test_create_invitation_link fails on a clean tree (stale max_uses==1 assertion vs the invite-default change in ba1cfad0/791f06e0); a pending crm activity_type migration also predates this work.

12. Open Questions / Decisions

  • Decided: soft-polymorphic (target_type, target_id) over Django GenericForeignKey — the repo avoids ContentType entirely, and soft refs are partition-friendly (no cross-table join, no django_content_type coupling). → ADR-019.
  • Decided: additive — do not migrate TaskComment/DealComment now.
  • Open: whether tasks/deals should switch their panels to CommentThread later (tracked in ADR-019 follow-ups), and whether to add a global /c/<id> resolver route (deferred; per-target routes suffice).

13. Bug / Change Log

2026-07-19 — Pulse threads and first-comment author notification

Type: Feature extension

Summary:

  • Added pulse as a universal-comment target. Create and list validate both tenant ownership and the caller’s F103 Pulse visibility before exposing the thread.
  • The first peer comment now notifies the Pulse author explicitly. Self-comments remain restrained; mention and prior-participant de-duplication still apply.
  • Notification routes include the member, Pulse, and comment identifiers; Android and iOS resolve the opaque route only through their typed allowlisted Pulse-thread destinations.
  • Shared KMP Pulse history loads one discussion lazily at a time to avoid an N+1 comment fan-out.

Proof: backend/tests/pulse/test_pulse_comments.py and android/shared/src/commonTest/.../PulseThreadScreenModelTest.kt.

2026-07-14 — Contract Graph ownership and proof alignment

Type: Documentation and proof-traceability change

Summary:

  • Added canonical contracts/features/F81.cue ownership for all five /api/comments/ surfaces.
  • Attached stable proof IDs for tenant isolation, replies, visibility, edit/delete authorization, resolution, pagination, upload linkage, and notification-event creation.
  • Kept browser/native runtime and live notification delivery gaps explicit rather than inferring them from source inspection or backend tests.

2026-07-13 — Comment parent made a validated soft reference

The parent wire field remains the same nullable UUID, but no longer creates a self-FK that must be rewritten for an organization-hash partition. Reply lookup, re-rooting, visibility, tenant validation, serialization, and soft-delete behavior remain unchanged.

2026-07-02 — Markdown rendering for comment bodies on mobile (KMP)

Type: Enhancement (mobile)

Summary:

  • The shared Compose (KMP) mobile shell now renders comment bodies as formatted markdown instead of raw text. Added a single reusable themed component LumiMarkdownText (in com.locuno.lumi.shared.ui, wrapping com.mikepenz:multiplatform-markdown-renderer-m3:0.43.0) skinned with Lumi design tokens, and wired it into CommentRow in android/shared/src/commonMain/kotlin/com/locuno/lumi/shared/ui/taskdetail/TaskDetailScreen.kt. Renders bold/italic, headings, lists, links, inline code, code blocks, blockquotes, and horizontal rules; blank input renders nothing; plain text renders as a paragraph.
  • Same component adopted across other authored bodies (announcements, messages, chat, brief) so all long-form surfaces share one markdown renderer.

Reason: comment bodies are authored markdown (body_md); mobile previously showed raw syntax.

Impact: iOS + Android + desktop (shared Compose) comment surfaces render formatted markdown.

2026-07-02 — Spec drift fix: correct §11 test counts (backend 34 → 51, frontend 17 → 15)

Type: Documentation (spec accuracy, docs-only)

  • §11 recorded 34 backend tests and 17 frontend unit tests from the 2026-06-21 audit. Actual today: backend/tests/test_comments.py has 51 tests (grew as TestCommentVisibility/TestCommentUpload/TestCommentBackfillMigration/TestListValidation cases landed — see the dated entries below), and the frontend has 15 (CommentThread.test.tsx 12 + InlineCommentLayer.test.tsx 3). §11 counts updated to match the files. The historical point-in-time counts in earlier §13 entries are left intact.

2026-07-02 — Spec drift fix: checked all §2 ACs; status updated to shipped

Type: Documentation (spec accuracy — ACs were [ ] despite §11 confirming all 11 items shipped).

Summary:

  • All 11 [ ] ACs in §2 flipped to [x] — confirmed shipped against §11 Implementation Audit (backend 34 tests ✅, frontend 17 unit tests ✅, deep-link + ?comment= focus working ✅).
  • Spec status updated from “agreed” → “shipped (backend + frontend; E2E written, pending live-stack run)”.
  • No code changes.

2026-06-21 — Comment attachments + emoji picker

Type: Feature

Summary:

  • Comment gains an optional upload FK (outbound → uploads.Upload, partition-safe per ADR-019); create validates the upload is org-owned + completed; a comment may carry text OR an attachment. Serializer exposes upload, upload_filename, upload_content_type, upload_byte_size, upload_url.
  • Frontend CommentThread composer: attach-a-file button (presign → S3 → complete, reusing F34 upload hooks) with a pending chip, attachment link rendered under each comment; a basic emoji picker inserts unicode into the body. New i18n keys: attach_file, remove_attachment, add_emoji.

Reason:

  • User request: “users can upload attachment when comment, can use some basic emoji?”

Impact:

  • All comment surfaces (doc/task/deal/…) gain a single attachment + emoji insertion. No reactions table.

Tests:

  • backend test_comments.py TestCommentUpload (upload required-completed, org-owned, text-or-upload).
  • frontend CommentThread.test.tsx: emoji insertion + attachment-link render.

2026-06-21 — Consolidate TaskComment + DealComment into universal Comment

Type: Refactor · Tech-debt removal (ADR-019 §3 amendment)

Summary:

  • Comment gains visibility (shared|private, default shared) and origin (user|system|ai, default user) — ported from DealComment (F50 §2.6). The list view ANDs a visibility filter for non-managers (_is_manager): they see shared + their own private; managers see all. Applies to roots AND replies, every target_type.
  • Comment gains an optional upload FK (org-owned + completed Upload); a comment must carry text OR an attachment.
  • Data migration comments.0003_migrate_task_deal_comments backfills work.TaskComment → Comment(target_type="task") and crm.DealComment → Comment(target_type="deal", visibility, origin), preserving created_at (auto_now_add set on insert, then corrected via queryset .update() which bypasses auto_now, in batches of 500). Idempotent: skips a row matching (org, target, author, body, created_at). DealComment.origin import/confirmation_link collapses to the narrower system.
  • Removed: TaskComment model/serializer/admin + TaskCommentViewSet (/api/task-comments/) + the TaskViewSet.comments/remove_comment nested actions + _notify_comment_mentions; DealComment model/serializer + DealViewSet.comments/delete_comment actions + _notify_mentions + the now-unused _is_org_manager. Table drops: work.0013_delete_taskcomment, crm.0018_alter_crmactivity_activity_type_delete_dealcomment (both depend on comments.0003 so the backfill runs first). ORIGIN_CHOICES kept in apps/crm/models.py (still used by DealActivity).

Reason:

  • Two parallel comment tables were tech debt and TaskComment had no organization_id (a tenant-key gap on a high-growth table). Consolidating now, while volumes are small, is cheap.

Impact:

  • All comment traffic flows through /api/comments/; the legacy per-surface endpoints are gone (the frontend is built in parallel against /api/comments/ only). No data loss — created_at and author preserved; deal visibility/origin preserved.

Tests:

  • test_comments.py: TestCommentVisibility (7), TestCommentUpload (6), TestCommentBackfillMigration (migration-executor end-to-end: seed legacy rows via historical models, migrate forward, assert target/visibility/origin/created_at). Old endpoint tests in test_work_graph.py, test_crm_extensions.py, test_coverage_gaps_r01.py removed (behavior now covered here). Full suite: 3305 pass (1 pre-existing unrelated test_create_invitation_link failure), coverage 98.96%.

2026-06-21 — Post-merge code-review hardening

Type: Bug Fix · Performance Fix

Summary:

  • Validate target_id/after/parent as UUIDs at the API boundary → malformed ids now return 400 instead of 500 (Django UUIDField raised ValueError).
  • Mutation echoes (create/edit/resolve) attach an empty _replies, so the serializer’s reply fallback never fires an unbounded SELECT (_serialize_one helper). Client refetches the list.
  • notify_comment bulk-fetches mention + thread-follow members in two id__in queries instead of one SELECT per participant.
  • Soft-delete tombstone is now the language-neutral sentinel "[deleted]" (never user-facing; the frontend renders the localized deleted_tombstone keyed off is_deleted).

Reason:

  • Code review (0 CRITICAL, 1 HIGH = the 500s; plus N+1 and i18n MEDIUM/LOW). Tenant isolation, permission gates, and notification-swallowing were confirmed correct — no change needed there.

Impact:

  • No API-shape change. Malformed ids fail cleanly; fewer queries per comment create on busy threads.

Tests:

  • test_comments.py: 3 new malformed-UUID→400 cases (TestListValidation). 37 pass; app 99% covered.

2026-06-21 — Universal comment system (F81) created

Type: Feature

Summary:

  • New comments app: soft-polymorphic Comment model, keyset-paginated API (list/create/edit/delete/resolve/reply), @mention + thread-follow notifications with ?comment= deep links. Reusable CommentThread + useComments; doc panel + Notion-style inline comments wired into DocViewer. Partition-ready schema (uuid7, org key, keyset, soft refs).

Reason:

  • Documents could not be commented on at all (“click discuss, still cannot comment”); commenting was fragmented across per-surface tables. One scalable system unifies it.

Impact:

  • Hub docs gain comments (panel + inline). Other surfaces can adopt CommentThread. No change to existing TaskComment/DealComment rows. No notification migration (already partition-ready).

Tests:

  • backend/tests/test_comments.py (isolation, threading, resolve, edit/delete, mentions, keyset), frontend/src/test/CommentThread.test.tsx, InlineCommentLayer.test.tsx, e2e Document comments.

Notes:

  • Inline anchors use W3C text-quote selectors (robust on plain markdown); orphaned anchors degrade to general comments. Future: partition comments by organization_id when a tenant warrants it — mechanical, no query change.

2026-07-12 — Mobile delete affordance capability parity

Type: Bug Fix · Mobile parity

Summary:

  • Shared task and deal comment rows now grant edit/delete to the author or users with the backend members.manage capability. Preset role names are no longer used as a client-side proxy.
  • Task detail reuses its existing effective-capability lookup; deal detail now loads the same /api/permissions/me/ contract. A failed lookup fails closed for other users while preserving author self-edit/delete.

Reason:

  • Task detail hid custom-role managers who had members.manage, while deal detail passed an empty role and hid every non-author delete action, even when the backend would allow it.

Tests:

  • Shared JVM model tests cover capability granted, missing, and failed lookup paths for task/deal detail; CommentSection derives the row affordance from author ownership plus the capability.