feat(ack): message acknowledgement / required-read tracking (LC-490) #481

Merged
longjacksonle merged 5 commits from feat/LC-490-message-acknowledgements into main 2026-06-28 06:49:08 +02:00

Implements LC-490: mark a message "needs acknowledgement" and track who has clicked Acknowledge, surfaced as a roster on the message. Mirrors the existing reaction (per-user roster + sub-region OOB) and pin (author/mod boolean toggle + full-bubble OOB) precedents. Stack unchanged (Askama + HTMX + Tailwind).

Behavior

  • The message author or a room moderator (same gate as delete) gets a Require acknowledgement / Clear acknowledgement item in the overflow menu.
  • A required message shows an "Acknowledgement required" badge + an Acknowledge button for every member; once clicked it flips to "Acknowledged" and the roster count ("Acknowledged by N", names in the tooltip) updates.
  • Everything is live: acknowledging fans out an Acknowledged event that re-renders the #ack-{id} sub-region per viewer; toggling the requirement fans out AckRequiredChanged that re-renders the whole bubble per viewer (so the bar + menu item appear/disappear for everyone).

Data model

  • 0073_acknowledgements.sql: message_ack_required (pin-shaped flag) + message_acks (reaction-shaped (message_id, user_id) roster); both ON DELETE CASCADE. Clearing the requirement drops the roster.
  • db::acks: set/clear required, is_required, required_ids_for_room (bulk page path), acknowledge (idempotent), rollup (count / acked_by_me / acker_ids).

Rendering correctness

MessageView gains ack: Option<AckState> (Some = required). The clobber risk (a full-bubble re-render from an edit/pin/bookmark wiping a required message's bar) is handled by computing ack centrally in load_message_view_for_viewer (covers the pin/bookmark/edit/ack WS single-message renders) and at the two inline edit sites (render_edited_message, patch_message); the room-list path bulk-gates on required_ids so the rollup only runs for flagged messages; DMs and brand-new messages are None.

Routes

  • POST /messages/{id}/ack - acknowledge (any room member; 400 if not required).
  • POST / DELETE /messages/{id}/ack-required - flag / clear (author or room moderator; 403 otherwise).

Tests

New routes_acks integration test: RBAC (non-author 403), 400 on acking a non-required message, require -> acknowledge -> roster count, idempotent ack, clear drops the roster. Added the ack field to the two hand-built MessageView test constructors and regenerated the LC-77 render fixtures (only an empty #ack region added).

just check, just test, just test-saas (incl. en/es i18n parity), just build-css all pass.

Implements LC-490: mark a message "needs acknowledgement" and track who has clicked Acknowledge, surfaced as a roster on the message. Mirrors the existing reaction (per-user roster + sub-region OOB) and pin (author/mod boolean toggle + full-bubble OOB) precedents. Stack unchanged (Askama + HTMX + Tailwind). ## Behavior - The message author or a room moderator (same gate as delete) gets a **Require acknowledgement** / **Clear acknowledgement** item in the overflow menu. - A required message shows an "Acknowledgement required" badge + an **Acknowledge** button for every member; once clicked it flips to "Acknowledged" and the roster count ("Acknowledged by N", names in the tooltip) updates. - Everything is live: acknowledging fans out an `Acknowledged` event that re-renders the `#ack-{id}` sub-region per viewer; toggling the requirement fans out `AckRequiredChanged` that re-renders the whole bubble per viewer (so the bar + menu item appear/disappear for everyone). ## Data model - `0073_acknowledgements.sql`: `message_ack_required` (pin-shaped flag) + `message_acks` (reaction-shaped `(message_id, user_id)` roster); both `ON DELETE CASCADE`. Clearing the requirement drops the roster. - `db::acks`: set/clear required, `is_required`, `required_ids_for_room` (bulk page path), `acknowledge` (idempotent), `rollup` (count / acked_by_me / acker_ids). ## Rendering correctness `MessageView` gains `ack: Option<AckState>` (`Some` = required). The clobber risk (a full-bubble re-render from an edit/pin/bookmark wiping a required message's bar) is handled by computing ack centrally in `load_message_view_for_viewer` (covers the pin/bookmark/edit/ack WS single-message renders) and at the two inline edit sites (`render_edited_message`, `patch_message`); the room-list path bulk-gates on `required_ids` so the rollup only runs for flagged messages; DMs and brand-new messages are `None`. ## Routes - `POST /messages/{id}/ack` - acknowledge (any room member; 400 if not required). - `POST` / `DELETE /messages/{id}/ack-required` - flag / clear (author or room moderator; 403 otherwise). ## Tests New `routes_acks` integration test: RBAC (non-author 403), 400 on acking a non-required message, require -> acknowledge -> roster count, idempotent ack, clear drops the roster. Added the `ack` field to the two hand-built `MessageView` test constructors and regenerated the LC-77 render fixtures (only an empty `#ack` region added). `just check`, `just test`, `just test-saas` (incl. en/es i18n parity), `just build-css` all pass.
Two tables mirroring the pin (needs-ack flag) and reaction (per-user roster)
precedents, plus db::acks (set/clear required, is_required, required_ids_for_room,
acknowledge, rollup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AckState on MessageView; partials/ack_bar.html (initial) + ws/ack_update.html
(live OOB on #ack-{id}); message.html renders the id-keyed region and the
author/mod-gated Require/Clear acknowledgement menu item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /messages/{id}/ack (any member) + POST/DELETE /ack-required (author/mod);
Acknowledged (sub-region) + AckRequiredChanged (full-bubble) events rendered
per-recipient. build_ack_view computed centrally in load_message_view_for_viewer
and at every full-bubble site so edits never wipe the bar; list path bulk-gates
on required_ids. en+es strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(ack): ack flow integration + MessageView.ack field in fixtures (LC-490)
Some checks failed
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 4s
check-secrets / Kingfisher (pull_request) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 6s
Check / clippy + fmt + tests (pull_request) Failing after 11s
42507c60dd
New routes_acks integration test (require/ack/roster/clear/RBAC); add ack field
to the two hand-built MessageView test constructors; regenerate the LC-77 render
fixtures (empty #ack region added by message.html).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-06-28 06:38:57 +02:00
style(ack): rustfmt routes_acks test (LC-490)
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / TruffleHog (pull_request) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 6s
check-secrets / Kingfisher (push) Successful in 7s
Check / clippy + fmt + tests (pull_request) Successful in 3m25s
Create release / Create release from merged PR (pull_request) Has been skipped
eeb4ccf34c
The test file was added after the prior fmt pass, so cargo fmt --check (CI gate)
flagged an over-long assert_eq. No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/LC-490-message-acknowledgements 2026-06-28 06:49:09 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!481
No description provided.