# Comments — current-system spec (parity bar for the rebuild)

> A single org-wide comment board reachable from a 💬 bell in the top nav, built so rian and Adi can leave each other asynchronous notes ("@Adi please update the prompt victoria project to PlusROI : Prompt Victoria in clockify" — the verbatim originating request, 2026-05-22). Any org member can post, anyone can resolve/reopen, authors can delete their own. Comments are **not** anchored to rows — a per-entry `time_entry_comments` table shipped and was dropped the same day (ADR #020/#022: "It doesn't need to be per row"). `@mentions` are cosmetic highlighting only — no notifications, no validation against real users. The bell shows a red unresolved-count badge on every app page.

## User workflows

1. **Leave a task for the other person:** click 💬 in the nav → type a note in the textarea (usually starting `@Adi …` or `@Rian …`) → Post. The other person sees the red badge on their next page load, opens the panel, does the task, clicks **Resolve**.
2. **Check what's outstanding:** the badge count = open comments. Open the panel → the **Open** tab lists them newest-first.
3. **Audit / undo:** the **Resolved** tab shows who resolved what; **Reopen** puts a comment back on the board. **Delete** (author-only) permanently removes one after a `window.confirm`.

## Complete behavior

### Posting

- B1. `postComment(formData)` (server action, `app/actions/comments.ts`) reads `formData.get('body')`, coerces to string, trims. Empty after trim → fail with `Comment body is required.`
- B2. All comment actions resolve the org via `getAppOrg(supabase)` (hardcoded slug `bowden-works`, `lib/org.ts` `APP_ORG_SLUG`); missing org → `Organization not found.`
- B3. `postComment` and `resolveComment` require `getEffectiveUser(supabase, org.id)`; null → `Not authenticated.` Attribution columns use **`effective_user_id`** (not the real user id): a comment posted while viewing-as Adi is authored *as Adi* (ADR #033).
- B4. Insert row: `{ org_id, author_id: effective_user_id, body }`. DB also enforces `check (length(trim(body)) > 0)`.
- B5. Every successful mutation (post/resolve/reopen/delete) runs `revalidatePath('/', 'layout')` then `redirect('/?comments=1')`. **Nothing reads the `comments=1` param** — it's vestigial. The panel stays open in practice only because the `(app)` layout (and the bell's client state) survives the soft navigation.
- B6. All failures go through `fail(msg)` = `redirect('/?error=' + encodeURIComponent(msg))` — surfaced by the app's global error banner, no inline form errors.

### Resolve / reopen lifecycle

- B7. A comment is "resolved" iff `resolved_at IS NOT NULL`. Two states only; no other status field.
- B8. `resolveComment(formData)` reads `id`; missing → `Missing comment id.` Updates `{ resolved_at: new Date().toISOString(), resolved_by: effective_user_id }` scoped `.eq('org_id', org.id).eq('id', id)`.
- B9. There is **no state precondition** — resolving an already-resolved comment re-stamps `resolved_at`/`resolved_by`; reopening an open comment is a no-op write.
- B10. `reopenComment(formData)` sets `{ resolved_at: null, resolved_by: null }`. It does **not** call `getEffectiveUser` (no attribution to stamp) — auth is enforced only by RLS + the org lookup failing for anon.
- B11. **Anyone in the org** may resolve or reopen **any** comment (RLS policy `comments_org_update`: `using/with check is_org_member(org_id)`). Shared board semantics — deliberate.

### Delete

- B12. `deleteComment(formData)` reads `id`, deletes scoped `.eq('org_id').eq('id')`. Like reopen, it does not call `getEffectiveUser`; authorization is pure RLS: `comments_author_or_admin_delete` = `author_id = auth.uid() OR current_user_is_super_admin()`.
- B13. UI gates the Delete button with `comment.author_id === currentUserId` where `currentUserId` is **`effective.real_user_id`** (layout passes the *real* id, while posts are attributed to the *effective* id — see wart in Edge cases).
- B14. Client-side `window.confirm('Delete this comment?')` before submit; no server-side confirmation.
- B15. Mutations never verify the row exists. A nonexistent id, or an id RLS hides, matches 0 rows → Supabase returns **no error** → the action "succeeds" (revalidate + redirect) with nothing changed. Silent no-op by design (well, by omission).

### Bell, badge, and panel (`components/comments-bell.tsx`, data from `app/(app)/layout.tsx`)

- B16. The `(app)` layout fetches, **on every render of every app page**: the newest **50** comments for the org (`select id, body, author_id, created_at, resolved_at, resolved_by … order created_at desc limit 50`), then one `profiles` lookup (`id, full_name, email`) for the union of `author_id` + `resolved_by` ids.
- B17. Display name = `full_name || email`; unresolvable or null id → `Unknown`. A resolved comment with null `resolved_by` renders "Resolved by someone".
- B18. `unresolvedCount` = count of rows **within the 50-row window** where `resolved_at` is null. If >50 comments exist, older unresolved ones are invisible AND uncounted (known wart, accepted at 2-user scale).
- B19. Badge: rendered only when `unresolvedCount > 0`; red pill (`#b91c1c` bg, white text) overlapping the 💬 button's top-right corner, showing the raw number.
- B20. The bell sits in the nav's right-side user cluster: user name → 💬 bell → View-as switcher (super admin only) → Sign out. Present on every `(app)` page.
- B21. Clicking 💬 toggles a dropdown panel (client state, default closed): 380px wide, max-height 70vh, absolute-positioned under the bell, right-aligned, z-index 30.
- B22. Panel header has **Open / Resolved** tab buttons (default Open). Tabs filter the same 50-row window client-side by `resolved_at` null/non-null; ordering stays newest-first. Empty states: `No open comments.` / `No resolved comments.`
- B23. Each item shows: author name (bold) · relative time (hover `title` = full `toLocaleString()` timestamp) · body (`white-space: pre-wrap`) · action row. Resolved items render dimmed (`opacity 0.7`, grey `#f7f7f7` background).
- B24. Action row: open comments get **Resolve**; resolved get "Resolved by {name}" + **Reopen**; **Delete** (red) appended per B13. All are inline forms posting to the server actions with a hidden `id` input.
- B25. `timeAgo(iso)`: `<60s → "Ns ago"`, `<3600s → "Nm ago"`, `<86400s → "Nh ago"`, else `"Nd ago"` (all `Math.round`).
- B26. `renderWithMentions(body)`: every match of `/@\w+/g` is wrapped in a highlight span (`background #fef3c7`, `color #78350f`). Pure display — no lookup, no linking, no notification routing.
- B27. Post form at the panel bottom: `textarea name="body" rows=2 required`, placeholder `Leave a note for the team… (e.g. @Adi please rename the project in Clockify)`, right-aligned **Post** button.

## Data model

`public.comments` (migration `20260522020001_global_comments.sql`, which also `drop table if exists public.time_entry_comments` — the abandoned per-entry design from `20260522000001`):

| column | type | semantics |
|---|---|---|
| `id` | uuid PK, `gen_random_uuid()` | |
| `org_id` | uuid NOT NULL → `organizations(id)` on delete cascade | board scope; always the `bowden-works` org today |
| `author_id` | uuid NULL → `auth.users(id)` **on delete set null** | effective user who posted; null after account deletion → renders "Unknown" |
| `body` | text NOT NULL, `check (length(trim(body)) > 0)` | free text; `@mentions` are just text |
| `resolved_at` | timestamptz NULL | null = open; non-null = resolved (the only lifecycle field) |
| `resolved_by` | uuid NULL → `auth.users(id)` on delete set null | effective user who resolved; cleared on reopen |
| `created_at` | timestamptz NOT NULL default `now()` | sort key (desc) |

Indexes: `idx_comments_org_created` on `(org_id, created_at desc)`; partial `idx_comments_org_unresolved` same key `where resolved_at is null`.

Reads of other tables: `organizations` (slug lookup), `profiles` (name resolution). No FK from `comments` to `profiles` — the profile join is done app-side in two queries.

## Permissions

No `lib/permissions.ts` function covers comments — gating is `getEffectiveUser` presence (post/resolve) plus RLS (helper functions `public.is_org_member(org_id)` and `public.current_user_is_super_admin()`):

| Action | Who | Enforced by |
|---|---|---|
| Read | any org member | RLS `comments_org_select` |
| Post | any org member, `author_id` must equal `auth.uid()` | RLS `comments_org_insert` (`with check is_org_member AND author_id = auth.uid()`) |
| Resolve / Reopen | any org member, any comment | RLS `comments_org_update` |
| Delete | author, or super admin | RLS `comments_author_or_admin_delete` |

View-as interaction: the server client swaps the JWT to the impersonated user, so `auth.uid()` = effective user; insert attribution (B3) therefore passes `with check`. RLS super-admin delete does NOT apply while viewing-as (the impersonated JWT isn't super admin).

## Edge cases & error behavior

- Empty/whitespace body → `Comment body is required.` (app) and the DB check as backstop.
- Missing `id` on resolve/reopen/delete → `Missing comment id.`
- All errors redirect to `/?error=<msg>` — the user lands on the home page even if they posted from elsewhere (the panel is global, so this is mostly invisible today).
- Nonexistent / RLS-hidden id on any mutation → silent success-shaped no-op (B15).
- **View-as delete wart:** the Delete button shows on comments where `author_id === real_user_id` (B13), but while viewing-as, RLS evaluates `auth.uid()` = the impersonated user — so a super admin in view-as sees Delete on *their own* comments yet the delete silently no-ops (B15 + B12). Conversely, comments they posted *while* in view-as are attributed to the impersonated user (B3) and show no Delete button to them after exiting only if authored ids differ. Rebuild should pick one identity for both attribution and button-gating.
- **Badge undercount past 50 comments** (B18) — the count is derived from the display window, not a real `count(*) where resolved_at is null` (the partial index for that query exists but is unused).
- Deleted auth user → `author_id`/`resolved_by` become null → "Unknown" / "Resolved by someone".
- Layout runs the 2-query fetch on **every** app-page render — acknowledged as "cheap today; could become a separate RPC if slow".
- No edit-after-post (delete + repost), no threading (`parent_id` was explicitly deferred), no notifications, no read/unread per user — the badge is global unresolved count, not "unread by me".

## UI notes

- Current UI is entirely inline-styled (the only shared classes: `nav-signout` for the bell button, `link-button` for actions, `muted`, `secondary`, `flex-row`).
- Fits rian's stated taste (transcript-requirements §17): icon-over-words (💬 with a count pill), detail-on-hover (full timestamp in `title`), compact rows, primary action (Post) front-and-center with destructive Delete de-emphasized to a small red text link. He specifically asked for exactly this shape: "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."
- No recorded complaints about this feature in the transcripts — it shipped matching the request and stayed untouched. Keep it this small.
- Active tab renders as primary button, inactive as `secondary`.

## Rebuild deltas

- **Carries over, re-anchored** to new table names (02-domain-model §`comments` and the parity mapping table); in scope for R1 parity (00-vision-and-scope §Roadmap).
- **Expansion, not pure parity:** 02-domain-model describes the rebuilt feature as "per-table/per-row anchored comments + global feed + unread bell" — i.e. it *re-adds* the anchored-comments idea the current system dropped (nullable anchor columns / polymorphic anchor + the global feed as the unanchored case). Design that anchor when building; the current system contributes only the global-board semantics above.
- **RLS deleted** — permissions move to the authz service (`services/authz/`, 04-architecture); comments get a thin `routers/comments.py`. The four RLS policies above become `can(actor, action, comment)` capability rules; fix the view-as identity wart while translating (one identity for attribution + gating, per the grant-header actor resolution).
- Badge should become a real unresolved count (and per-user unread is a natural extension once `users` exist per 02-domain-model), not a window-derived count.

## Test specs

- T-COM-101: given two org members, when one posts a comment, then the other sees it in the bell (newest-first) and the badge increments. *(carried: COM-101)*
- T-COM-102: given an open comment, when any org member resolves it, then `resolved_at` and `resolved_by` are set and the unresolved badge count decrements. *(carried: COM-102)*
- T-COM-103: given a comment, then the author and a super admin can delete it; any other member's delete is denied (currently: silently no-ops). *(carried: COM-103)*
- T-COM-104: given a resolved comment, when any org member reopens it, then `resolved_at`/`resolved_by` are null and it returns to the Open tab and badge count.
- T-COM-105: given an empty or whitespace-only body, when posted, then the action fails with `Comment body is required.` and no row is inserted.
- T-COM-106: given 51+ comments with an unresolved one at position 51, then (current) the badge undercounts / (rebuild) the badge counts all unresolved.
- T-COM-107: given a body containing `@Adi fix this`, when rendered, then `@Adi` is visually highlighted and no notification is sent.
- T-COM-108: given a super admin viewing-as Adi, when they post a comment, then `author_id` is Adi's user id and the comment displays as authored by Adi after exiting view-as.
- T-COM-109: given view-as active, delete authorization uses one consistent identity for both button visibility and enforcement (current behavior: button shows on real-user-authored comments but RLS no-ops — documents the wart).
- T-COM-110: given a comment whose author's account was deleted, then it renders with author "Unknown" (and "Resolved by someone" if `resolved_by` was nulled).
- T-COM-111: given open and resolved comments, then the Open/Resolved tabs partition them correctly with the documented empty-state messages.
- T-COM-112: given an unauthenticated request to any comment action, then it fails (org lookup / auth guard) and writes nothing.
- T-COM-113: given a mutation with a nonexistent comment id, then no row changes and (rebuild) the user gets a not-found error instead of the current silent success.
- T-COM-114: given comment ages of 30s / 5m / 3h / 2d, then `timeAgo` renders `30s ago` / `5m ago` / `3h ago` / `2d ago`.
