# Comments board + bell

**Status:** in production
**Lives at:** the 💬 bell in the app header (`components/CommentsBell.tsx`)
on every `(app)` page; API `app/routers/comments.py`; logic
`app/services/comments.py`; display helpers `frontend/src/lib/comments.ts`.
**Spec:** `/srv/apps/work/docs/rebuild/01-current-system/features/comments.md`
(B1–B27) + 02-domain-model §3 (`comments`) + 06-test-plan §7.3.

## Summary

One org-wide comment board reachable from a bell in the top nav, so
rian and Adi can leave each other asynchronous notes ("@Adi please
rename the project in Clockify"). Any tenant member can post; anyone can
resolve/reopen; authors (and admins) can delete their own. A red badge
shows the number of unresolved comments on every page.

## Why

The verbatim originating ask (2026-05-22): *"Just a comment icon in the
top menu bar and I can @ a user to leave a comment for him. He can see
it and resolve it."* The feature shipped matching that request in the
old app and stayed untouched — R1 keeps it exactly this small.

## Behavior

- **Global board (R1).** Comments are NOT anchored to rows — one
  tenant-scoped feed, newest-first. The table carries nullable
  `entity_type`/`entity_id` anchor columns for a future per-row feature
  (02 §3); the R1 UI ignores them.
- **Post.** Type a note (usually `@Adi …`) and Post. Empty/whitespace is
  rejected with `Comment body is required.`. Attribution is the
  **effective** user (posting while viewing-as Adi authors *as Adi*).
- **@mentions are cosmetic.** Every `/@\w+/` match is highlighted; no
  lookup, no validation, no notification routing.
- **Resolve / reopen lifecycle.** A comment is resolved iff
  `resolved_at` is set (two states, no status column). Resolving stamps
  `resolved_at`=now + `resolved_by`=the effective user. Reopening clears
  both. **No state precondition** (B9): re-resolving an already-resolved
  comment re-stamps to the new actor/time; reopening an already-open
  comment is a no-op write. Any member may resolve/reopen any comment
  (shared-board semantics).
- **Delete.** Author-only, or a super admin who is not viewing-as —
  **enforced server-side**. A non-author member's delete is refused with
  an explicit 403 (the old app's silent RLS no-op is fixed here — the
  spec's rebuild delta). A `window.confirm` guards the button.
- **Bell + badge.** The bell shows a red pill with the unresolved count
  on every page; the count is a REAL `count(*) where resolved_at is null`
  (tenant-wide), not the old window-derived undercount. The dropdown
  panel has Open / Resolved tabs (default Open) over the newest-50
  window, each item showing author · relative time (full timestamp on
  hover) · body · actions. Empty states: `No open comments.` /
  `No resolved comments.`
- **Relative time** buckets (B25): `<60s → "Ns ago"`, `<3600s → "Nm ago"`,
  `<86400s → "Nh ago"`, else `"Nd ago"` (all rounded).

## Constraints & edge cases

- **Deleted accounts.** `author_id`/`resolved_by` are `ON DELETE SET
  NULL`; a null author renders "Unknown", a null resolver renders
  "Resolved by someone".
- **Missing / cross-tenant id.** Any mutation on an id that doesn't
  exist in the actor's tenant returns 404 `COMMENT_NOT_FOUND` — not the
  legacy silent success-shaped no-op.
- **Unauthenticated.** The default-deny gate 401s every `/api/comments`
  call without a session before a handler runs; the service also refuses
  a resolved actor with no tenant membership (`NOT_A_MEMBER`).
- **Window vs count.** The panel lists the newest 50; the badge counts
  ALL unresolved. Past 50 comments an old unresolved one is off the panel
  but still counted (acceptable at 2-user scale; the badge is now honest).
- **Not built (deferred):** per-row anchoring (columns exist, no UI),
  threading, edit-after-post, notifications, per-user unread. The badge
  is global unresolved, not "unread by me".

## Permissions

No capability grid entry covers comments (there is no
`can_comment`). Authorization is derived from the resolved Actor — the
same identity fields `can()`/`scope()` read — replacing the four legacy
RLS policies:

| Action | Who | Enforced by |
|---|---|---|
| Read / post / resolve / reopen | any tenant **member** (owner/manager/worker, or a not-viewing-as super admin) | `services/comments._require_member` |
| Delete | the **author**, or a not-viewing-as super admin | `services/comments._can_delete` — the SAME predicate that sets each row's `can_delete` flag |

**One identity fixes the view-as wart.** Attribution AND delete gating
both use the EFFECTIVE user (`actor.user_id`). The `can_delete` flag the
API returns per row and the delete handler's check are the same function
on the same identity, so button visibility and enforcement never
disagree (the old app showed Delete on real-user-authored comments while
RLS evaluated the impersonated user — that split is gone).

## Open considerations

- Per-row anchored comments (the columns are already in the schema) — a
  natural R2 extension; the global board becomes the unanchored case.
- Per-user unread (a real "unread by me" badge) once multiple humans use
  it regularly — needs a per-user read marker, deferred.
- The 50-row panel window could become a paged/RPC fetch if the board
  ever grows large; the badge count already scales (indexed partial
  count).

## Tests

Backend (pytest, `tests/comments/test_comments_api.py`, 06-test-plan §7.3):
- T-COM-101 — post visible to the other member, newest-first, badge increments
- T-COM-102 — any member resolves; stamps set; badge decrements
- T-COM-103 — author + super-admin delete; other member gets an explicit 403
- T-COM-104 — reopen returns the comment to Open + the badge
- T-COM-105 — empty/whitespace body rejected, no row
- T-COM-106 — badge counts ALL unresolved past the 50-row window
- T-COM-108 — view-as attributes the post to the effective user
- T-COM-109 — view-as delete uses ONE identity for visibility + enforcement
- T-COM-110 — deleted author → "Unknown"; nulled resolver → null (→ "someone")
- T-COM-111 — Open/Resolved partition is derivable from the payload
- T-COM-112 — unauthenticated actions refused, nothing written
- T-COM-113 — mutation on a nonexistent id → 404, no change
- T-COM-115 — B9: resolve re-stamps; reopen-open is a no-op (no precondition)

Frontend (vitest, `frontend/src/lib/comments.test.ts`, 06-test-plan §8):
- T-COM-107 — `@mention` highlighted, no notification (pure render segments)
- T-COM-114 — `timeAgo` buckets 30s / 5m / 3h / 2d
- (plus the tab partition, empty-state copy, and "someone" resolver label)

## Changelog

- 2026-07-08 — M7 slice B. Ported the global comment board + bell to
  FastAPI + the SPA. New: `services/comments.py`, `routers/comments.py`
  (`/api/comments` GET/POST + `/{id}/resolve|reopen` + DELETE),
  `components/CommentsBell.tsx`, `lib/comments.ts`. Rebuild deltas from
  the current system: RLS → service-layer authz on the resolved Actor;
  author-only delete ENFORCED server-side (explicit 403, not a silent
  no-op); the badge is a real unresolved `count(*)`, not window-derived;
  ONE (effective) identity for attribution + delete gating (view-as wart
  fixed); nonexistent-id mutations 404 instead of succeeding silently.
