feat(canned): per-user canned responses / saved replies (LC-487) #487

Merged
longjacksonle merged 4 commits from feat/LC-487-canned-responses into main 2026-06-28 21:20:44 +02:00

LC-487 - Canned responses / saved replies

Per-user reusable message snippets, invoked from the composer as personal slash commands and expanded through the existing slash dispatch + autocomplete (as the ticket suggested).

Data

Migration 0076_canned_responses.sql adds a user-scoped canned_responses table (UNIQUE(user_id, name)). It is intentionally a separate table rather than slash_commands_custom: that table's scope_id is an INTEGER and its UNIQUE(scope_kind, scope_id, name) would force names to be globally unique across users. Here names are unique per user.

Reuse

db::slash gains insert_canned / list_canned_for_user / get_canned_for_user / delete_canned, returning StaticText CustomCommands so the existing machinery treats a saved reply exactly like a personal static-text command:

  • Autocomplete + help: custom_for_user unions global admin commands with the caller's canned responses (a global name wins a collision, so the list shows one row).
  • Dispatch: try_dispatch checks canned responses after built-ins and global commands (neither can be shadowed). {args} is substituted with whatever the user typed after the name, then posted as a normal message, identical to a global static_text command.

Management

A new "Saved replies" tab in Settings creates/lists/deletes canned responses (name + optional description + body). Name validation mirrors the admin custom-command rule (lowercase [a-z0-9_-], no built-in shadowing); the body is capped at the message length limit since it is posted as a message. Account deletion is unaffected (canned rows are keyed by user_id; a follow-up could add them to the delete sweep, but they are private and harmless).

Tests

db_canned: insert/get/list scoping, per-user (not global) name uniqueness, owner-scoped delete. Existing routes_slash HTTP tests still pass (global behavior unchanged). just check, just test, just test-saas all green (i18n parity covers the new en/es keys); Tailwind rebuilt.

Not operator-visible (additive migration, no env/config/contract change).

🤖 Generated with Claude Code

## LC-487 - Canned responses / saved replies Per-user reusable message snippets, invoked from the composer as personal slash commands and expanded through the existing slash dispatch + autocomplete (as the ticket suggested). ### Data Migration `0076_canned_responses.sql` adds a user-scoped `canned_responses` table (`UNIQUE(user_id, name)`). It is intentionally a separate table rather than `slash_commands_custom`: that table's `scope_id` is an INTEGER and its `UNIQUE(scope_kind, scope_id, name)` would force names to be globally unique across users. Here names are unique per user. ### Reuse `db::slash` gains `insert_canned` / `list_canned_for_user` / `get_canned_for_user` / `delete_canned`, returning `StaticText` `CustomCommand`s so the existing machinery treats a saved reply exactly like a personal static-text command: - **Autocomplete + help**: `custom_for_user` unions global admin commands with the caller's canned responses (a global name wins a collision, so the list shows one row). - **Dispatch**: `try_dispatch` checks canned responses after built-ins and global commands (neither can be shadowed). `{args}` is substituted with whatever the user typed after the name, then posted as a normal message, identical to a global static_text command. ### Management A new "Saved replies" tab in Settings creates/lists/deletes canned responses (name + optional description + body). Name validation mirrors the admin custom-command rule (lowercase `[a-z0-9_-]`, no built-in shadowing); the body is capped at the message length limit since it is posted as a message. Account deletion is unaffected (canned rows are keyed by `user_id`; a follow-up could add them to the delete sweep, but they are private and harmless). ### Tests `db_canned`: insert/get/list scoping, per-user (not global) name uniqueness, owner-scoped delete. Existing `routes_slash` HTTP tests still pass (global behavior unchanged). `just check`, `just test`, `just test-saas` all green (i18n parity covers the new en/es keys); Tailwind rebuilt. Not operator-visible (additive migration, no env/config/contract change). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Migration 0076 adds a user-scoped canned_responses table (UNIQUE per user+name). db::slash gains insert_canned / list_canned_for_user / get_canned_for_user / delete_canned, returning StaticText CustomCommands so the existing slash dispatch + autocomplete handle them like personal commands. Kept off slash_commands_custom because that table's integer scope_id + global UNIQUE can't express per-user names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
custom_for_user unions global commands with the caller's canned responses (global wins name collisions) for the autocomplete/help list; try_dispatch checks canned after built-ins and global commands, expanding {args} the same way as a global static_text command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New Settings tab to create/list/delete canned responses (name + optional description + body), with name validation mirroring admin custom commands (no built-in shadowing) and the body capped at the message length limit. New i18n keys (en+es).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(canned): canned-response CRUD, per-user uniqueness, owner-scoped delete (LC-487)
All checks were successful
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / Kingfisher (push) Successful in 6s
check-secrets / Kingfisher (pull_request) Successful in 6s
Create release / Create release from merged PR (pull_request) Has been skipped
check-secrets / TruffleHog (pull_request) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 4s
Check / clippy + fmt + tests (pull_request) Successful in 5m38s
2c57599e97
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 21:15:42 +02:00
longjacksonle deleted branch feat/LC-487-canned-responses 2026-06-28 21:20:44 +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!487
No description provided.