From 4f1807ccdc195ff8de452f986642a49112ecb3a6 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 25 Aug 2026 17:57:36 +0000 Subject: [PATCH 1/7] feat(tickets): staff-only notes and canned replies (#1522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things support asked for. > **Stacked on #1515.** That branch carries a production fix (staff replies to email tickets 500), and this work sits on top of it. Merge #1515 first; the diff here collapses to just this feature once it lands. ## Staff-only notes Staff can write a note on a ticket that the person who opened it never sees. It shows in the timeline on a muted yellow card, marked "Internal note". Modelled as a flag on the existing messages rather than a table of its own, so a note keeps its place in the conversation — the point of writing one is that it sits next to the message it is about. A note is never emailed, and cannot move the ticket's status: nobody outside the team has seen it, so it cannot change who is owed a response. It is also excluded from the unread badges for the same reason. ### Keeping them from leaking This is the part worth reviewing. Rather than remembering to filter in each place, the blanket `From for ApiTicket` is gone, replaced by: ```rust ApiTicket::for_viewer(full, viewer_is_staff) ``` A conversion reachable by `.into()` is one that will eventually be reached without anybody deciding whether the reader may see notes. Making the viewer an argument means the compiler asks. It pointed at five call sites, **two of which would have leaked**: the response to claiming a ticket, and the account ticket list. Tests cover the reporter reading with a claim token, reading after claiming, the account ticket list, and a non-staff attempt to write a note. ## Canned replies The answers from the runbook, grouped by situation (scope quota, package quota, package size, reserved scopes, deletions), in a picker next to the reply box for staff. - **Inserting appends rather than replaces**, so a draft already being written is not thrown away. - **The parts a person has to fill in are in brackets**, as in the runbook, and anything left unreplaced is listed under the box before sending. Sending "I've increased your scope quota to `[new_limit]`" verbatim is the obvious way to misuse a template. - **The warning does not block.** Square brackets are ordinary prose, and a draft that cannot be sent is worse than one that is flagged. ### An assumption to confirm **The templates live in the frontend, not the database.** They are house policy, they change about as often as the policy pages they link to, and keeping them in the repo means a wording change gets reviewed like any other user-facing copy — at the cost of a deploy to change one. If support would rather edit them in the admin UI, they can move; say so and I'll do it. Two of the runbook's entries said only "explain the denial reason" rather than giving wording, so I wrote a short opener with a `[reason]` placeholder for those (package size denied, reserved scope denied). Worth a look — they're mine, not yours. ## Testing 241 API tests pass, 4 of them new here. Clippy `-D warnings`, `cargo fmt`, `sqlx prepare --check`, `deno task lint:frontend`, `deno fmt` and license headers clean. Not verified in a browser — local dev still 404s on every route for me. The API behaviour is covered by tests; the visual side (the yellow note card, the template picker) wants an eye. --- ...c87bdcad9d348336a79cdd885c857383649e.json} | 4 +- ...e5441217624541e3ae1d5290508eaf5c8e137.json | 14 + ...915fc67f35cd985bc36f07ee2e493d8fe256.json} | 4 +- ...71db3e0c259e55bb283aca5e906f42226ff9.json} | 4 +- ...b378bc96d4dea56a77c6d9a5f296132c4e9d.json} | 4 +- ...244595b53e61c9b6893281a001f37290b183.json} | 4 +- ...23d526fab3ea4bb23cb3d2fb13047c32eb57.json} | 4 +- ...b44a1db42731670786336d0ca71cdcb12eaa.json} | 7 +- ...66993dca4594d7d4352049bcaa9c1421e298.json} | 4 +- ...9780563402a9766b21e0f96d4a4d02120f54.json} | 4 +- ...2fe89098de9e295e74df26d8f1d649297415.json} | 28 +- ...bc7495df524296893c6b6c2c10ca3a957595.json} | 4 +- ...68a8321ca644a6d59627e00bcc8d4db3d4ac.json} | 4 +- ...0cf6f2364a1e991c5e0a5b6d6bab4f90a52c.json} | 4 +- ...e0be3528dec88bb11de0c569be7cc123d7f1.json} | 4 +- ...4858a27171e79780830f72033eab6e0b3355.json} | 4 +- ...9e9d9d26f66580971ca1075d36dd0711f500.json} | 4 +- .../20260825021500_ticket_internal_notes.sql | 19 ++ api/src/api/admin.rs | 8 +- api/src/api/hooks.rs | 259 ++++++++++++++++++ api/src/api/self_user.rs | 10 +- api/src/api/tickets.rs | 76 +++-- api/src/api/types.rs | 38 ++- api/src/db/database.rs | 50 ++-- api/src/db/sql_fragments.rs | 18 +- crates/jsr_types/src/models.rs | 7 + frontend/islands/TicketMessageInput.tsx | 91 +++++- frontend/routes/ticket.tsx | 23 +- frontend/utils/api_types.ts | 5 + frontend/utils/ticket_templates.ts | 100 +++++++ 30 files changed, 690 insertions(+), 119 deletions(-) rename api/.sqlx/{query-650219f99e95565978f0fa07414e6c848b23bb98519edf7710a313bc5313916c.json => query-06dab3d7d24a01618be3bbf9a87dc87bdcad9d348336a79cdd885c857383649e.json} (80%) create mode 100644 api/.sqlx/query-12e63d98e2f365c8534cc98ecdbe5441217624541e3ae1d5290508eaf5c8e137.json rename api/.sqlx/{query-08ee5e443acf814f218f90e7cf5b8daccf74190f22d1f9e817a6bba429a12418.json => query-1ddc7109f349bc0b4aa6a6d89ee7915fc67f35cd985bc36f07ee2e493d8fe256.json} (82%) rename api/.sqlx/{query-46104fe4115f8b7781202724a4b3100493d0f8aadac543daf23f1dd70ac223c5.json => query-30b37ec2cd587664d22a42f78fab71db3e0c259e55bb283aca5e906f42226ff9.json} (82%) rename api/.sqlx/{query-e58894e9eb4fd14386b687179db0ab9740b143f4c3e13d3fc9c50a1c4159ad37.json => query-32cf4c5aa5cd5edf30ea2c376d5cb378bc96d4dea56a77c6d9a5f296132c4e9d.json} (80%) rename api/.sqlx/{query-682a2784ecf25a930388608d47d724703cb027dc2906176522fac01e33713632.json => query-6ce03466782da8f12d27850e8574244595b53e61c9b6893281a001f37290b183.json} (82%) rename api/.sqlx/{query-00f364049696faac2c1f38339caa8178a728dd651caf54cc2b9c2b0dbf404269.json => query-7d169e4ad7cc5fe7b09d5c3f62d623d526fab3ea4bb23cb3d2fb13047c32eb57.json} (78%) rename api/.sqlx/{query-4d4db475da4257e609e8f91dd0246327deba3cef0e81588f6bf475de5ad82e08.json => query-845b908b97658622ae136485449cb44a1db42731670786336d0ca71cdcb12eaa.json} (72%) rename api/.sqlx/{query-1b07c6bcd5224f813241269bea23a2fce07d8c28c9e030d4b40fbbd8c6f0217b.json => query-917ec41a2316a316e5a5bf04373866993dca4594d7d4352049bcaa9c1421e298.json} (82%) rename api/.sqlx/{query-10bfb547b81856d698c8d6d16679f196019d87cc47b7da6ee60ce298aeab471a.json => query-9886bb6bf28d849671834a9f19619780563402a9766b21e0f96d4a4d02120f54.json} (82%) rename api/.sqlx/{query-42451426dea855662dda75785a2d298bfb8ccd58c9719b47e644049625283306.json => query-a3c353c6ed89238b4db3748a48b72fe89098de9e295e74df26d8f1d649297415.json} (78%) rename api/.sqlx/{query-1b11ef3226b26d622d249d85715e9b64d7902ddeee08596dbc6e8e9f0eb79c6e.json => query-aabe59cf8c1ce9acf6fcf7e7e447bc7495df524296893c6b6c2c10ca3a957595.json} (80%) rename api/.sqlx/{query-946195554f59db594829b59fc44dd9086307eb3f86b55f1a099b1485815b5f07.json => query-af50ebb9b7a0302ff345f2cc822168a8321ca644a6d59627e00bcc8d4db3d4ac.json} (80%) rename api/.sqlx/{query-4742f927a546d964e8eee1f228a9f30bc57228198b68286ddde3e679ee213174.json => query-c80f65a0a17b45a42580398f6f3b0cf6f2364a1e991c5e0a5b6d6bab4f90a52c.json} (89%) rename api/.sqlx/{query-d24f58126801f799b2b89b74ccbae1e8a8892d6736ecc35681d24c7ea3cd9ab2.json => query-c8233202c42f9d5c3f03592feedfe0be3528dec88bb11de0c569be7cc123d7f1.json} (82%) rename api/.sqlx/{query-ad2418b621f6507dca8d7b53ef0511233435e5271415e7fdb153103e92e96ac8.json => query-e0d71d55b8aac7f6059b6e76fd614858a27171e79780830f72033eab6e0b3355.json} (82%) rename api/.sqlx/{query-6fb3d69cd7f0ed2b082d124345e4c73945ceb8bb29c0007d6f1625a6990ad979.json => query-f9604d10f19c3f5779c21a03d8ac9e9d9d26f66580971ca1075d36dd0711f500.json} (82%) create mode 100644 api/migrations/20260825021500_ticket_internal_notes.sql create mode 100644 frontend/utils/ticket_templates.ts diff --git a/api/.sqlx/query-650219f99e95565978f0fa07414e6c848b23bb98519edf7710a313bc5313916c.json b/api/.sqlx/query-06dab3d7d24a01618be3bbf9a87dc87bdcad9d348336a79cdd885c857383649e.json similarity index 80% rename from api/.sqlx/query-650219f99e95565978f0fa07414e6c848b23bb98519edf7710a313bc5313916c.json rename to api/.sqlx/query-06dab3d7d24a01618be3bbf9a87dc87bdcad9d348336a79cdd885c857383649e.json index 37642aa3b..2b832af9b 100644 --- a/api/.sqlx/query-650219f99e95565978f0fa07414e6c848b23bb98519edf7710a313bc5313916c.json +++ b/api/.sqlx/query-06dab3d7d24a01618be3bbf9a87dc87bdcad9d348336a79cdd885c857383649e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM users WHERE id = $1\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", + "query": "DELETE FROM users WHERE id = $1\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", "describe": { "columns": [ { @@ -96,5 +96,5 @@ null ] }, - "hash": "650219f99e95565978f0fa07414e6c848b23bb98519edf7710a313bc5313916c" + "hash": "06dab3d7d24a01618be3bbf9a87dc87bdcad9d348336a79cdd885c857383649e" } diff --git a/api/.sqlx/query-12e63d98e2f365c8534cc98ecdbe5441217624541e3ae1d5290508eaf5c8e137.json b/api/.sqlx/query-12e63d98e2f365c8534cc98ecdbe5441217624541e3ae1d5290508eaf5c8e137.json new file mode 100644 index 000000000..35328f451 --- /dev/null +++ b/api/.sqlx/query-12e63d98e2f365c8534cc98ecdbe5441217624541e3ae1d5290508eaf5c8e137.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE tickets SET updated_at = now() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "12e63d98e2f365c8534cc98ecdbe5441217624541e3ae1d5290508eaf5c8e137" +} diff --git a/api/.sqlx/query-08ee5e443acf814f218f90e7cf5b8daccf74190f22d1f9e817a6bba429a12418.json b/api/.sqlx/query-1ddc7109f349bc0b4aa6a6d89ee7915fc67f35cd985bc36f07ee2e493d8fe256.json similarity index 82% rename from api/.sqlx/query-08ee5e443acf814f218f90e7cf5b8daccf74190f22d1f9e817a6bba429a12418.json rename to api/.sqlx/query-1ddc7109f349bc0b4aa6a6d89ee7915fc67f35cd985bc36f07ee2e493d8fe256.json index 1832d3833..c2bf2f771 100644 --- a/api/.sqlx/query-08ee5e443acf814f218f90e7cf5b8daccf74190f22d1f9e817a6bba429a12418.json +++ b/api/.sqlx/query-1ddc7109f349bc0b4aa6a6d89ee7915fc67f35cd985bc36f07ee2e493d8fe256.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE users SET scope_limit = $1 WHERE id = $2\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", + "query": "UPDATE users SET scope_limit = $1 WHERE id = $2\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", "describe": { "columns": [ { @@ -97,5 +97,5 @@ null ] }, - "hash": "08ee5e443acf814f218f90e7cf5b8daccf74190f22d1f9e817a6bba429a12418" + "hash": "1ddc7109f349bc0b4aa6a6d89ee7915fc67f35cd985bc36f07ee2e493d8fe256" } diff --git a/api/.sqlx/query-46104fe4115f8b7781202724a4b3100493d0f8aadac543daf23f1dd70ac223c5.json b/api/.sqlx/query-30b37ec2cd587664d22a42f78fab71db3e0c259e55bb283aca5e906f42226ff9.json similarity index 82% rename from api/.sqlx/query-46104fe4115f8b7781202724a4b3100493d0f8aadac543daf23f1dd70ac223c5.json rename to api/.sqlx/query-30b37ec2cd587664d22a42f78fab71db3e0c259e55bb283aca5e906f42226ff9.json index 933747628..990f715df 100644 --- a/api/.sqlx/query-46104fe4115f8b7781202724a4b3100493d0f8aadac543daf23f1dd70ac223c5.json +++ b/api/.sqlx/query-30b37ec2cd587664d22a42f78fab71db3e0c259e55bb283aca5e906f42226ff9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO users (name, email, avatar_url, gitlab_id, is_blocked, is_staff)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT(gitlab_id) DO UPDATE\n SET name = $1, email = $2, avatar_url = $3\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", + "query": "INSERT INTO users (name, email, avatar_url, gitlab_id, is_blocked, is_staff)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT(gitlab_id) DO UPDATE\n SET name = $1, email = $2, avatar_url = $3\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", "describe": { "columns": [ { @@ -101,5 +101,5 @@ null ] }, - "hash": "46104fe4115f8b7781202724a4b3100493d0f8aadac543daf23f1dd70ac223c5" + "hash": "30b37ec2cd587664d22a42f78fab71db3e0c259e55bb283aca5e906f42226ff9" } diff --git a/api/.sqlx/query-e58894e9eb4fd14386b687179db0ab9740b143f4c3e13d3fc9c50a1c4159ad37.json b/api/.sqlx/query-32cf4c5aa5cd5edf30ea2c376d5cb378bc96d4dea56a77c6d9a5f296132c4e9d.json similarity index 80% rename from api/.sqlx/query-e58894e9eb4fd14386b687179db0ab9740b143f4c3e13d3fc9c50a1c4159ad37.json rename to api/.sqlx/query-32cf4c5aa5cd5edf30ea2c376d5cb378bc96d4dea56a77c6d9a5f296132c4e9d.json index e04822d26..35b5ceeee 100644 --- a/api/.sqlx/query-e58894e9eb4fd14386b687179db0ab9740b143f4c3e13d3fc9c50a1c4159ad37.json +++ b/api/.sqlx/query-32cf4c5aa5cd5edf30ea2c376d5cb378bc96d4dea56a77c6d9a5f296132c4e9d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" FROM users WHERE id = $1", + "query": "SELECT id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" FROM users WHERE id = $1", "describe": { "columns": [ { @@ -96,5 +96,5 @@ null ] }, - "hash": "e58894e9eb4fd14386b687179db0ab9740b143f4c3e13d3fc9c50a1c4159ad37" + "hash": "32cf4c5aa5cd5edf30ea2c376d5cb378bc96d4dea56a77c6d9a5f296132c4e9d" } diff --git a/api/.sqlx/query-682a2784ecf25a930388608d47d724703cb027dc2906176522fac01e33713632.json b/api/.sqlx/query-6ce03466782da8f12d27850e8574244595b53e61c9b6893281a001f37290b183.json similarity index 82% rename from api/.sqlx/query-682a2784ecf25a930388608d47d724703cb027dc2906176522fac01e33713632.json rename to api/.sqlx/query-6ce03466782da8f12d27850e8574244595b53e61c9b6893281a001f37290b183.json index 84dda537b..23d6719b0 100644 --- a/api/.sqlx/query-682a2784ecf25a930388608d47d724703cb027dc2906176522fac01e33713632.json +++ b/api/.sqlx/query-6ce03466782da8f12d27850e8574244595b53e61c9b6893281a001f37290b183.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE users SET is_staff = $1 WHERE id = $2\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", + "query": "UPDATE users SET is_staff = $1 WHERE id = $2\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", "describe": { "columns": [ { @@ -97,5 +97,5 @@ null ] }, - "hash": "682a2784ecf25a930388608d47d724703cb027dc2906176522fac01e33713632" + "hash": "6ce03466782da8f12d27850e8574244595b53e61c9b6893281a001f37290b183" } diff --git a/api/.sqlx/query-00f364049696faac2c1f38339caa8178a728dd651caf54cc2b9c2b0dbf404269.json b/api/.sqlx/query-7d169e4ad7cc5fe7b09d5c3f62d623d526fab3ea4bb23cb3d2fb13047c32eb57.json similarity index 78% rename from api/.sqlx/query-00f364049696faac2c1f38339caa8178a728dd651caf54cc2b9c2b0dbf404269.json rename to api/.sqlx/query-7d169e4ad7cc5fe7b09d5c3f62d623d526fab3ea4bb23cb3d2fb13047c32eb57.json index 2a77ee45d..751c77d51 100644 --- a/api/.sqlx/query-00f364049696faac2c1f38339caa8178a728dd651caf54cc2b9c2b0dbf404269.json +++ b/api/.sqlx/query-7d169e4ad7cc5fe7b09d5c3f62d623d526fab3ea4bb23cb3d2fb13047c32eb57.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE tickets SET status = $2, updated_at = now()\n WHERE id = $1 AND status NOT IN ('closed', 'spam')", + "query": "UPDATE tickets SET status = $2, updated_at = now()\n WHERE id = $1 AND status NOT IN ('closed', 'spam')", "describe": { "columns": [], "parameters": { @@ -24,5 +24,5 @@ }, "nullable": [] }, - "hash": "00f364049696faac2c1f38339caa8178a728dd651caf54cc2b9c2b0dbf404269" + "hash": "7d169e4ad7cc5fe7b09d5c3f62d623d526fab3ea4bb23cb3d2fb13047c32eb57" } diff --git a/api/.sqlx/query-4d4db475da4257e609e8f91dd0246327deba3cef0e81588f6bf475de5ad82e08.json b/api/.sqlx/query-845b908b97658622ae136485449cb44a1db42731670786336d0ca71cdcb12eaa.json similarity index 72% rename from api/.sqlx/query-4d4db475da4257e609e8f91dd0246327deba3cef0e81588f6bf475de5ad82e08.json rename to api/.sqlx/query-845b908b97658622ae136485449cb44a1db42731670786336d0ca71cdcb12eaa.json index 57a674cdc..5d75fdd32 100644 --- a/api/.sqlx/query-4d4db475da4257e609e8f91dd0246327deba3cef0e81588f6bf475de5ad82e08.json +++ b/api/.sqlx/query-845b908b97658622ae136485449cb44a1db42731670786336d0ca71cdcb12eaa.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO ticket_messages (ticket_id, author, direction, message, email_message_id)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id", + "query": "INSERT INTO ticket_messages (ticket_id, author, direction, message, email_message_id, internal)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id", "describe": { "columns": [ { @@ -25,12 +25,13 @@ } }, "Text", - "Text" + "Text", + "Bool" ] }, "nullable": [ false ] }, - "hash": "4d4db475da4257e609e8f91dd0246327deba3cef0e81588f6bf475de5ad82e08" + "hash": "845b908b97658622ae136485449cb44a1db42731670786336d0ca71cdcb12eaa" } diff --git a/api/.sqlx/query-1b07c6bcd5224f813241269bea23a2fce07d8c28c9e030d4b40fbbd8c6f0217b.json b/api/.sqlx/query-917ec41a2316a316e5a5bf04373866993dca4594d7d4352049bcaa9c1421e298.json similarity index 82% rename from api/.sqlx/query-1b07c6bcd5224f813241269bea23a2fce07d8c28c9e030d4b40fbbd8c6f0217b.json rename to api/.sqlx/query-917ec41a2316a316e5a5bf04373866993dca4594d7d4352049bcaa9c1421e298.json index f4aacf0e5..12eae0a1b 100644 --- a/api/.sqlx/query-1b07c6bcd5224f813241269bea23a2fce07d8c28c9e030d4b40fbbd8c6f0217b.json +++ b/api/.sqlx/query-917ec41a2316a316e5a5bf04373866993dca4594d7d4352049bcaa9c1421e298.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE users SET github_id = $1 WHERE id = $2\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", + "query": "UPDATE users SET github_id = $1 WHERE id = $2\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", "describe": { "columns": [ { @@ -97,5 +97,5 @@ null ] }, - "hash": "1b07c6bcd5224f813241269bea23a2fce07d8c28c9e030d4b40fbbd8c6f0217b" + "hash": "917ec41a2316a316e5a5bf04373866993dca4594d7d4352049bcaa9c1421e298" } diff --git a/api/.sqlx/query-10bfb547b81856d698c8d6d16679f196019d87cc47b7da6ee60ce298aeab471a.json b/api/.sqlx/query-9886bb6bf28d849671834a9f19619780563402a9766b21e0f96d4a4d02120f54.json similarity index 82% rename from api/.sqlx/query-10bfb547b81856d698c8d6d16679f196019d87cc47b7da6ee60ce298aeab471a.json rename to api/.sqlx/query-9886bb6bf28d849671834a9f19619780563402a9766b21e0f96d4a4d02120f54.json index 7b19d357d..4a240645d 100644 --- a/api/.sqlx/query-10bfb547b81856d698c8d6d16679f196019d87cc47b7da6ee60ce298aeab471a.json +++ b/api/.sqlx/query-9886bb6bf28d849671834a9f19619780563402a9766b21e0f96d4a4d02120f54.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE users SET is_blocked = $1 WHERE id = $2\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", + "query": "UPDATE users SET is_blocked = $1 WHERE id = $2\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", "describe": { "columns": [ { @@ -97,5 +97,5 @@ null ] }, - "hash": "10bfb547b81856d698c8d6d16679f196019d87cc47b7da6ee60ce298aeab471a" + "hash": "9886bb6bf28d849671834a9f19619780563402a9766b21e0f96d4a4d02120f54" } diff --git a/api/.sqlx/query-42451426dea855662dda75785a2d298bfb8ccd58c9719b47e644049625283306.json b/api/.sqlx/query-a3c353c6ed89238b4db3748a48b72fe89098de9e295e74df26d8f1d649297415.json similarity index 78% rename from api/.sqlx/query-42451426dea855662dda75785a2d298bfb8ccd58c9719b47e644049625283306.json rename to api/.sqlx/query-a3c353c6ed89238b4db3748a48b72fe89098de9e295e74df26d8f1d649297415.json index efe2534fe..15b407816 100644 --- a/api/.sqlx/query-42451426dea855662dda75785a2d298bfb8ccd58c9719b47e644049625283306.json +++ b/api/.sqlx/query-a3c353c6ed89238b4db3748a48b72fe89098de9e295e74df26d8f1d649297415.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n ticket_messages.id as \"message_id\", ticket_messages.ticket_id as \"message_ticket_id\", ticket_messages.author as \"message_author\", ticket_messages.author_email as \"message_author_email\", ticket_messages.author_name as \"message_author_name\", ticket_messages.author_email_verified as \"message_author_email_verified\", ticket_messages.direction as \"message_direction: TicketMessageDirection\", ticket_messages.email_message_id as \"message_email_message_id\", ticket_messages.message as \"message_message\", ticket_messages.updated_at as \"message_updated_at\", ticket_messages.created_at as \"message_created_at\",\n users.id as \"user_id?\", users.name as \"user_name?\", users.avatar_url as \"user_avatar_url?\", users.github_id as \"user_github_id?\", users.gitlab_id as \"user_gitlab_id?\", users.updated_at as \"user_updated_at?\", users.created_at as \"user_created_at?\"\n FROM ticket_messages\n LEFT JOIN users ON users.id = ticket_messages.author\n WHERE ticket_messages.ticket_id = $1\n ORDER BY ticket_messages.created_at", + "query": "SELECT\n ticket_messages.id as \"message_id\", ticket_messages.ticket_id as \"message_ticket_id\", ticket_messages.author as \"message_author\", ticket_messages.author_email as \"message_author_email\", ticket_messages.author_name as \"message_author_name\", ticket_messages.author_email_verified as \"message_author_email_verified\", ticket_messages.direction as \"message_direction: TicketMessageDirection\", ticket_messages.email_message_id as \"message_email_message_id\", ticket_messages.internal as \"message_internal\", ticket_messages.message as \"message_message\", ticket_messages.updated_at as \"message_updated_at\", ticket_messages.created_at as \"message_created_at\",\n users.id as \"user_id?\", users.name as \"user_name?\", users.avatar_url as \"user_avatar_url?\", users.github_id as \"user_github_id?\", users.gitlab_id as \"user_gitlab_id?\", users.updated_at as \"user_updated_at?\", users.created_at as \"user_created_at?\"\n FROM ticket_messages\n LEFT JOIN users ON users.id = ticket_messages.author\n WHERE ticket_messages.ticket_id = $1\n ORDER BY ticket_messages.created_at", "describe": { "columns": [ { @@ -55,51 +55,56 @@ }, { "ordinal": 8, + "name": "message_internal", + "type_info": "Bool" + }, + { + "ordinal": 9, "name": "message_message", "type_info": "Text" }, { - "ordinal": 9, + "ordinal": 10, "name": "message_updated_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 11, "name": "message_created_at", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 12, "name": "user_id?", "type_info": "Uuid" }, { - "ordinal": 12, + "ordinal": 13, "name": "user_name?", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 14, "name": "user_avatar_url?", "type_info": "Text" }, { - "ordinal": 14, + "ordinal": 15, "name": "user_github_id?", "type_info": "Int8" }, { - "ordinal": 15, + "ordinal": 16, "name": "user_gitlab_id?", "type_info": "Int8" }, { - "ordinal": 16, + "ordinal": 17, "name": "user_updated_at?", "type_info": "Timestamptz" }, { - "ordinal": 17, + "ordinal": 18, "name": "user_created_at?", "type_info": "Timestamptz" } @@ -124,11 +129,12 @@ false, false, false, + false, true, true, false, false ] }, - "hash": "42451426dea855662dda75785a2d298bfb8ccd58c9719b47e644049625283306" + "hash": "a3c353c6ed89238b4db3748a48b72fe89098de9e295e74df26d8f1d649297415" } diff --git a/api/.sqlx/query-1b11ef3226b26d622d249d85715e9b64d7902ddeee08596dbc6e8e9f0eb79c6e.json b/api/.sqlx/query-aabe59cf8c1ce9acf6fcf7e7e447bc7495df524296893c6b6c2c10ca3a957595.json similarity index 80% rename from api/.sqlx/query-1b11ef3226b26d622d249d85715e9b64d7902ddeee08596dbc6e8e9f0eb79c6e.json rename to api/.sqlx/query-aabe59cf8c1ce9acf6fcf7e7e447bc7495df524296893c6b6c2c10ca3a957595.json index 3fa524a20..c1529b3e3 100644 --- a/api/.sqlx/query-1b11ef3226b26d622d249d85715e9b64d7902ddeee08596dbc6e8e9f0eb79c6e.json +++ b/api/.sqlx/query-aabe59cf8c1ce9acf6fcf7e7e447bc7495df524296893c6b6c2c10ca3a957595.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" FROM users WHERE gitlab_id = $1", + "query": "SELECT id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" FROM users WHERE gitlab_id = $1", "describe": { "columns": [ { @@ -96,5 +96,5 @@ null ] }, - "hash": "1b11ef3226b26d622d249d85715e9b64d7902ddeee08596dbc6e8e9f0eb79c6e" + "hash": "aabe59cf8c1ce9acf6fcf7e7e447bc7495df524296893c6b6c2c10ca3a957595" } diff --git a/api/.sqlx/query-946195554f59db594829b59fc44dd9086307eb3f86b55f1a099b1485815b5f07.json b/api/.sqlx/query-af50ebb9b7a0302ff345f2cc822168a8321ca644a6d59627e00bcc8d4db3d4ac.json similarity index 80% rename from api/.sqlx/query-946195554f59db594829b59fc44dd9086307eb3f86b55f1a099b1485815b5f07.json rename to api/.sqlx/query-af50ebb9b7a0302ff345f2cc822168a8321ca644a6d59627e00bcc8d4db3d4ac.json index 1dc29fd8c..1ca8cc898 100644 --- a/api/.sqlx/query-946195554f59db594829b59fc44dd9086307eb3f86b55f1a099b1485815b5f07.json +++ b/api/.sqlx/query-af50ebb9b7a0302ff345f2cc822168a8321ca644a6d59627e00bcc8d4db3d4ac.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" FROM users WHERE github_id = $1", + "query": "SELECT id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" FROM users WHERE github_id = $1", "describe": { "columns": [ { @@ -96,5 +96,5 @@ null ] }, - "hash": "946195554f59db594829b59fc44dd9086307eb3f86b55f1a099b1485815b5f07" + "hash": "af50ebb9b7a0302ff345f2cc822168a8321ca644a6d59627e00bcc8d4db3d4ac" } diff --git a/api/.sqlx/query-4742f927a546d964e8eee1f228a9f30bc57228198b68286ddde3e679ee213174.json b/api/.sqlx/query-c80f65a0a17b45a42580398f6f3b0cf6f2364a1e991c5e0a5b6d6bab4f90a52c.json similarity index 89% rename from api/.sqlx/query-4742f927a546d964e8eee1f228a9f30bc57228198b68286ddde3e679ee213174.json rename to api/.sqlx/query-c80f65a0a17b45a42580398f6f3b0cf6f2364a1e991c5e0a5b6d6bab4f90a52c.json index c628b46fe..b8ea56c01 100644 --- a/api/.sqlx/query-4742f927a546d964e8eee1f228a9f30bc57228198b68286ddde3e679ee213174.json +++ b/api/.sqlx/query-c80f65a0a17b45a42580398f6f3b0cf6f2364a1e991c5e0a5b6d6bab4f90a52c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n tickets.id as \"ticket_id\", tickets.ticket_number as \"ticket_ticket_number\", tickets.kind as \"ticket_kind: TicketKind\", tickets.creator as \"ticket_creator\", tickets.reporter_email as \"ticket_reporter_email\", tickets.reporter_name as \"ticket_reporter_name\", tickets.subject as \"ticket_subject\", tickets.claim_token as \"ticket_claim_token\", tickets.meta as \"ticket_meta\", tickets.status as \"ticket_status: TicketStatus\", tickets.closed_at as \"ticket_closed_at\", tickets.updated_at as \"ticket_updated_at\", tickets.created_at as \"ticket_created_at\",\n users.id as \"user_id?\", users.name as \"user_name?\", users.email as \"user_email\", users.avatar_url as \"user_avatar_url?\", users.github_id as \"user_github_id\", users.gitlab_id as \"user_gitlab_id\", users.is_blocked as \"user_is_blocked?\", users.is_staff as \"user_is_staff?\", users.scope_limit as \"user_scope_limit?\", users.updated_at as \"user_updated_at?\", users.created_at as \"user_created_at?\",\n(SELECT COUNT(scope_invites.created_at) FROM scope_invites WHERE scope_invites.target_user_id = users.id) as \"user_invite_count\",\n(SELECT COUNT(scopes.created_at) FROM scopes WHERE scopes.creator = users.id) as \"user_scope_usage\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"user_newer_ticket_messages_count\"\n FROM tickets\n LEFT JOIN users ON users.id = tickets.creator\n WHERE tickets.id = $1", + "query": "SELECT\n tickets.id as \"ticket_id\", tickets.ticket_number as \"ticket_ticket_number\", tickets.kind as \"ticket_kind: TicketKind\", tickets.creator as \"ticket_creator\", tickets.reporter_email as \"ticket_reporter_email\", tickets.reporter_name as \"ticket_reporter_name\", tickets.subject as \"ticket_subject\", tickets.claim_token as \"ticket_claim_token\", tickets.meta as \"ticket_meta\", tickets.status as \"ticket_status: TicketStatus\", tickets.closed_at as \"ticket_closed_at\", tickets.updated_at as \"ticket_updated_at\", tickets.created_at as \"ticket_created_at\",\n users.id as \"user_id?\", users.name as \"user_name?\", users.email as \"user_email\", users.avatar_url as \"user_avatar_url?\", users.github_id as \"user_github_id\", users.gitlab_id as \"user_gitlab_id\", users.is_blocked as \"user_is_blocked?\", users.is_staff as \"user_is_staff?\", users.scope_limit as \"user_scope_limit?\", users.updated_at as \"user_updated_at?\", users.created_at as \"user_created_at?\",\n(SELECT COUNT(scope_invites.created_at) FROM scope_invites WHERE scope_invites.target_user_id = users.id) as \"user_invite_count\",\n(SELECT COUNT(scopes.created_at) FROM scopes WHERE scopes.creator = users.id) as \"user_scope_usage\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"user_newer_ticket_messages_count\"\n FROM tickets\n LEFT JOIN users ON users.id = tickets.creator\n WHERE tickets.id = $1", "describe": { "columns": [ { @@ -200,5 +200,5 @@ null ] }, - "hash": "4742f927a546d964e8eee1f228a9f30bc57228198b68286ddde3e679ee213174" + "hash": "c80f65a0a17b45a42580398f6f3b0cf6f2364a1e991c5e0a5b6d6bab4f90a52c" } diff --git a/api/.sqlx/query-d24f58126801f799b2b89b74ccbae1e8a8892d6736ecc35681d24c7ea3cd9ab2.json b/api/.sqlx/query-c8233202c42f9d5c3f03592feedfe0be3528dec88bb11de0c569be7cc123d7f1.json similarity index 82% rename from api/.sqlx/query-d24f58126801f799b2b89b74ccbae1e8a8892d6736ecc35681d24c7ea3cd9ab2.json rename to api/.sqlx/query-c8233202c42f9d5c3f03592feedfe0be3528dec88bb11de0c569be7cc123d7f1.json index 532312447..29d885a36 100644 --- a/api/.sqlx/query-d24f58126801f799b2b89b74ccbae1e8a8892d6736ecc35681d24c7ea3cd9ab2.json +++ b/api/.sqlx/query-c8233202c42f9d5c3f03592feedfe0be3528dec88bb11de0c569be7cc123d7f1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO users (name, email, avatar_url, github_id, gitlab_id, is_blocked, is_staff)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", + "query": "INSERT INTO users (name, email, avatar_url, github_id, gitlab_id, is_blocked, is_staff)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", "describe": { "columns": [ { @@ -102,5 +102,5 @@ null ] }, - "hash": "d24f58126801f799b2b89b74ccbae1e8a8892d6736ecc35681d24c7ea3cd9ab2" + "hash": "c8233202c42f9d5c3f03592feedfe0be3528dec88bb11de0c569be7cc123d7f1" } diff --git a/api/.sqlx/query-ad2418b621f6507dca8d7b53ef0511233435e5271415e7fdb153103e92e96ac8.json b/api/.sqlx/query-e0d71d55b8aac7f6059b6e76fd614858a27171e79780830f72033eab6e0b3355.json similarity index 82% rename from api/.sqlx/query-ad2418b621f6507dca8d7b53ef0511233435e5271415e7fdb153103e92e96ac8.json rename to api/.sqlx/query-e0d71d55b8aac7f6059b6e76fd614858a27171e79780830f72033eab6e0b3355.json index 26f2d4961..fc04d892d 100644 --- a/api/.sqlx/query-ad2418b621f6507dca8d7b53ef0511233435e5271415e7fdb153103e92e96ac8.json +++ b/api/.sqlx/query-e0d71d55b8aac7f6059b6e76fd614858a27171e79780830f72033eab6e0b3355.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO users (name, email, avatar_url, github_id, is_blocked, is_staff)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT(github_id) DO UPDATE\n SET name = $1, email = $2, avatar_url = $3\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", + "query": "INSERT INTO users (name, email, avatar_url, github_id, is_blocked, is_staff)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT(github_id) DO UPDATE\n SET name = $1, email = $2, avatar_url = $3\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", "describe": { "columns": [ { @@ -101,5 +101,5 @@ null ] }, - "hash": "ad2418b621f6507dca8d7b53ef0511233435e5271415e7fdb153103e92e96ac8" + "hash": "e0d71d55b8aac7f6059b6e76fd614858a27171e79780830f72033eab6e0b3355" } diff --git a/api/.sqlx/query-6fb3d69cd7f0ed2b082d124345e4c73945ceb8bb29c0007d6f1625a6990ad979.json b/api/.sqlx/query-f9604d10f19c3f5779c21a03d8ac9e9d9d26f66580971ca1075d36dd0711f500.json similarity index 82% rename from api/.sqlx/query-6fb3d69cd7f0ed2b082d124345e4c73945ceb8bb29c0007d6f1625a6990ad979.json rename to api/.sqlx/query-f9604d10f19c3f5779c21a03d8ac9e9d9d26f66580971ca1075d36dd0711f500.json index d03263e5f..9fd4043a6 100644 --- a/api/.sqlx/query-6fb3d69cd7f0ed2b082d124345e4c73945ceb8bb29c0007d6f1625a6990ad979.json +++ b/api/.sqlx/query-f9604d10f19c3f5779c21a03d8ac9e9d9d26f66580971ca1075d36dd0711f500.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE users SET gitlab_id = $1 WHERE id = $2\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", + "query": "UPDATE users SET gitlab_id = $1 WHERE id = $2\n RETURNING id, name, email, avatar_url, updated_at, created_at, github_id, gitlab_id, is_blocked, is_staff, scope_limit,\n(SELECT COUNT(created_at) FROM scope_invites WHERE target_user_id = id) as \"invite_count!\",\n(SELECT COUNT(created_at) FROM scopes WHERE creator = id) as \"scope_usage!\",\n(CASE WHEN users.is_staff THEN (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'inbound'\n) ELSE (\n SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND (\n SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1\n ) = 'outbound'\n) END) as \"newer_ticket_messages_count!\" ", "describe": { "columns": [ { @@ -97,5 +97,5 @@ null ] }, - "hash": "6fb3d69cd7f0ed2b082d124345e4c73945ceb8bb29c0007d6f1625a6990ad979" + "hash": "f9604d10f19c3f5779c21a03d8ac9e9d9d26f66580971ca1075d36dd0711f500" } diff --git a/api/migrations/20260825021500_ticket_internal_notes.sql b/api/migrations/20260825021500_ticket_internal_notes.sql new file mode 100644 index 000000000..4031e67fa --- /dev/null +++ b/api/migrations/20260825021500_ticket_internal_notes.sql @@ -0,0 +1,19 @@ +-- Staff notes on a ticket: written in the ticket, never emailed, never shown to +-- the person who opened it. +-- +-- Modelled as a flag on the existing messages rather than a table of its own so +-- that a note keeps its place in the conversation — the point of writing one is +-- that it sits next to the message it is about. + +ALTER TABLE ticket_messages ADD COLUMN internal bool NOT NULL DEFAULT false; + +-- A note is written in the JSR interface by a signed-in staff member, and is +-- never sent anywhere, so it can carry no email identity of its own. +ALTER TABLE ticket_messages ADD CONSTRAINT ticket_messages_internal_is_unsent_and_authored CHECK ( + internal = false OR (author IS NOT NULL AND direction = 'outbound' AND email_message_id IS NULL) +); + +-- The ticket page loads a whole conversation at a time, and every such read now +-- filters on this, so it belongs in the index that serves them. +DROP INDEX idx_ticket_messages_ticket_id; +CREATE INDEX idx_ticket_messages_ticket_id ON ticket_messages (ticket_id, created_at) INCLUDE (internal); diff --git a/api/src/api/admin.rs b/api/src/api/admin.rs index 8e7e7fc23..7aa57ae0e 100644 --- a/api/src/api/admin.rs +++ b/api/src/api/admin.rs @@ -357,7 +357,11 @@ pub async fn list_tickets(req: Request) -> ApiResult> { .list_tickets(start, limit, maybe_search, maybe_sort, maybe_status) .await?; Ok(ApiList { - items: tickets.into_iter().map(|ticket| ticket.into()).collect(), + // This endpoint is behind check_admin_access, so notes are included. + items: tickets + .into_iter() + .map(|ticket| ApiTicket::for_viewer(ticket, true)) + .collect(), total, }) } @@ -382,7 +386,7 @@ pub async fn patch_ticket(mut req: Request) -> ApiResult { }); }; - Ok(ticket.into()) + Ok(ApiTicket::for_viewer(ticket, true)) } #[instrument(name = "GET /api/admin/audit_logs", skip(req))] diff --git a/api/src/api/hooks.rs b/api/src/api/hooks.rs index 8babc0af8..428795f4b 100644 --- a/api/src/api/hooks.rs +++ b/api/src/api/hooks.rs @@ -814,6 +814,35 @@ mod integration { list.items } + /// The claim token of an unclaimed ticket. Not exposed over the API — it only + /// ever reaches the reporter by email — so it is read from the row. + async fn claim_token_of(t: &TestSetup, ticket_id: uuid::Uuid) -> uuid::Uuid { + t.ephemeral_database + .database + .as_ref() + .unwrap() + .get_ticket(ticket_id) + .await + .unwrap() + .unwrap() + .0 + .claim_token + .expect("an unclaimed ticket has a claim token") + } + + fn message_bodies(overview: &crate::api::ApiTicketOverview) -> Vec { + overview + .events + .iter() + .filter_map(|event| match event { + crate::api::ApiTicketMessageOrAuditLog::Message { message } => { + Some(message.message.clone()) + } + _ => None, + }) + .collect() + } + /// The stored message rows for a ticket, in order. Used for the parts of a /// message that the API deliberately does not expose. async fn ticket_messages( @@ -1203,6 +1232,236 @@ mod integration { assert_eq!(after.status, TicketStatus::WaitingOnUser); } + #[tokio::test] + async fn a_staff_note_is_never_shown_to_the_reporter() { + let mut t = TestSetup::new().await; + + deliver(&mut t, inbound("", "Help", json!([]))).await; + let ticket = all_tickets(&mut t).await.remove(0); + + let staff_token = t.staff_user.token.clone(); + let mut resp = t + .http() + .post(format!("/api/tickets/{}", ticket.id)) + .token(Some(&staff_token)) + .body_json( + json!({ "message": "looks like a squatter", "internal": true }), + ) + .call() + .await + .unwrap(); + let note: crate::api::ApiTicketMessage = resp.expect_ok().await; + assert!(note.internal); + + // Staff see it. + let mut resp = t + .http() + .get(format!("/api/tickets/{}", ticket.id)) + .token(Some(&staff_token)) + .call() + .await + .unwrap(); + let staff_view: crate::api::ApiTicketOverview = resp.expect_ok().await; + assert!( + message_bodies(&staff_view) + .iter() + .any(|m| m == "looks like a squatter") + ); + + // The reporter, holding the claim token from their auto-reply, does not. + let claim_token = claim_token_of(&t, ticket.id).await; + let mut resp = t + .http() + .get(format!("/api/tickets/{}?claim={claim_token}", ticket.id)) + .token(None) + .call() + .await + .unwrap(); + let reporter_view: crate::api::ApiTicketOverview = resp.expect_ok().await; + let bodies = message_bodies(&reporter_view); + assert!( + !bodies.iter().any(|m| m == "looks like a squatter"), + "the note leaked to the reporter: {bodies:?}" + ); + // The rest of the conversation is still there. + assert!(bodies.iter().any(|m| m == "I cannot publish my package.")); + } + + #[tokio::test] + async fn a_staff_note_does_not_survive_claiming_the_ticket() { + let mut t = TestSetup::new().await; + + deliver(&mut t, inbound("", "Help", json!([]))).await; + let ticket = all_tickets(&mut t).await.remove(0); + let claim_token = claim_token_of(&t, ticket.id).await; + + let staff_token = t.staff_user.token.clone(); + t.http() + .post(format!("/api/tickets/{}", ticket.id)) + .token(Some(&staff_token)) + .body_json(json!({ "message": "internal only", "internal": true })) + .call() + .await + .unwrap(); + + // Claiming returns the ticket to the person who just claimed it, who is not + // staff however the ticket got to them. + let user_token = t.user1.token.clone(); + let mut resp = t + .http() + .post(format!( + "/api/tickets/{}/claim?claim={claim_token}", + ticket.id + )) + .token(Some(&user_token)) + .call() + .await + .unwrap(); + let claimed: ApiTicket = resp.expect_ok().await; + assert!( + !claimed + .messages + .iter() + .any(|m| m.message == "internal only"), + "the note leaked in the claim response" + ); + + // Nor in their own account view of it afterwards. + let mut resp = t + .http() + .get("/api/user/tickets") + .token(Some(&user_token)) + .call() + .await + .unwrap(); + let own: Vec = resp.expect_ok().await; + assert!( + !own + .iter() + .flat_map(|t| &t.messages) + .any(|m| m.message == "internal only"), + "the note leaked in the account ticket list" + ); + } + + #[tokio::test] + async fn a_staff_member_can_note_on_a_ticket_they_opened_themselves() { + let mut t = TestSetup::new().await; + + // Staff open tickets like anyone else — filing one about their own scope, + // or just trying something out. The author then *is* the creator, which is + // the one combination where a note is written by the person the ticket + // belongs to. + let staff_token = t.staff_user.token.clone(); + let mut resp = t + .http() + .post("/api/tickets") + .token(Some(&staff_token)) + .body_json(json!({ + "kind": crate::db::TicketKind::Other, + "meta": {}, + "message": "my own ticket", + })) + .call() + .await + .unwrap(); + let own: ApiTicket = resp.expect_ok().await; + + let mut resp = t + .http() + .post(format!("/api/tickets/{}", own.id)) + .token(Some(&staff_token)) + .body_json( + json!({ "message": "note on my own ticket", "internal": true }), + ) + .call() + .await + .unwrap(); + let note: crate::api::ApiTicketMessage = resp.expect_ok().await; + assert!(note.internal); + // A note is from the JSR side whoever writes it, so it is outbound even + // here. Anything else violates the check constraint on the table. + assert_eq!(note.direction, crate::db::TicketMessageDirection::Outbound); + } + + #[tokio::test] + async fn a_note_cannot_be_written_by_anyone_but_staff() { + let mut t = TestSetup::new().await; + + deliver(&mut t, inbound("", "Help", json!([]))).await; + let ticket = all_tickets(&mut t).await.remove(0); + let claim_token = claim_token_of(&t, ticket.id).await; + + // The reporter, via their claim link. + let mut resp = t + .http() + .post(format!("/api/tickets/{}?claim={claim_token}", ticket.id)) + .token(None) + .body_json(json!({ "message": "sneaky", "internal": true })) + .call() + .await + .unwrap(); + resp.expect_err(StatusCode::FORBIDDEN).await; + + // And an ordinary signed-in account that owns a ticket of its own. + let user_token = t.user1.token.clone(); + let mut resp = t + .http() + .post("/api/tickets") + .token(Some(&user_token)) + .body_json(json!({ + "kind": crate::db::TicketKind::Other, + "meta": {}, + "message": "hello", + })) + .call() + .await + .unwrap(); + let own: ApiTicket = resp.expect_ok().await; + + let mut resp = t + .http() + .post(format!("/api/tickets/{}", own.id)) + .token(Some(&user_token)) + .body_json(json!({ "message": "sneaky", "internal": true })) + .call() + .await + .unwrap(); + resp.expect_err(StatusCode::FORBIDDEN).await; + } + + #[tokio::test] + async fn a_staff_note_sends_no_email_and_does_not_move_the_status() { + let mut t = TestSetup::new().await; + + deliver(&mut t, inbound("", "Help", json!([]))).await; + let ticket = all_tickets(&mut t).await.remove(0); + assert_eq!(ticket.status, TicketStatus::Open); + + let staff_token = t.staff_user.token.clone(); + t.http() + .post(format!("/api/tickets/{}", ticket.id)) + .token(Some(&staff_token)) + .body_json(json!({ "message": "note to self", "internal": true })) + .call() + .await + .unwrap(); + + // A reply would have moved this to waiting_on_user. A note says nothing to + // the reporter, so it must not. + let after = all_tickets(&mut t).await.remove(0); + assert_eq!(after.status, TicketStatus::Open); + + // And nothing was queued to be sent. + let db = t.ephemeral_database.database.as_ref().unwrap(); + assert!( + db.list_stale_email_deliveries(0, 10) + .await + .unwrap() + .is_empty() + ); + } + #[tokio::test] async fn wrong_webhook_password_is_rejected() { let mut t = TestSetup::new().await; diff --git a/api/src/api/self_user.rs b/api/src/api/self_user.rs index ffd2f2741..6adc407ed 100644 --- a/api/src/api/self_user.rs +++ b/api/src/api/self_user.rs @@ -322,7 +322,15 @@ pub async fn list_tickets(req: Request) -> ApiResult> { let db = req.data::().unwrap(); let tickets = db.list_tickets_for_user(current_user.id).await?; - Ok(tickets.into_iter().map(|scope| scope.into()).collect()) + // Somebody's own tickets, read as themselves: staff notes are withheld even + // if the reader happens to be staff, because this is the account view rather + // than the admin one. + Ok( + tickets + .into_iter() + .map(|ticket| ApiTicket::for_viewer(ticket, false)) + .collect(), + ) } #[cfg(test)] diff --git a/api/src/api/tickets.rs b/api/src/api/tickets.rs index 8918e60e2..9e51f66c8 100644 --- a/api/src/api/tickets.rs +++ b/api/src/api/tickets.rs @@ -53,9 +53,10 @@ pub fn tickets_router() -> Router { } /// How the caller proved they are allowed to see a ticket. +#[derive(PartialEq)] enum TicketAccess { /// A signed-in account: the ticket's owner, or a staff member. - User, + User { staff: bool }, /// The reporter of an email-opened ticket nobody has claimed yet, holding the /// claim token from the auto-reply. They can read and reply, but the ticket is /// not theirs until they claim it. @@ -82,9 +83,9 @@ fn check_ticket_access( let iam = req.iam(); let current_user = iam.check_current_user_access()?; - if ticket.creator == Some(current_user.id) || iam.check_admin_access().is_ok() - { - return Ok(TicketAccess::User); + let staff = iam.check_admin_access().is_ok(); + if ticket.creator == Some(current_user.id) || staff { + return Ok(TicketAccess::User { staff }); } // Deliberately not "forbidden": that would confirm the ticket exists to @@ -131,10 +132,15 @@ pub async fn get_handler(req: Request) -> ApiResult { let (ticket, creator, messages) = db.get_ticket(id).await?.ok_or(ApiError::TicketNotFound)?; - check_ticket_access(&req, &ticket)?; + let access = check_ticket_access(&req, &ticket)?; + let staff = access == TicketAccess::User { staff: true }; let mut events: Vec = messages .into_iter() + // Staff notes are part of the same conversation, so they are dropped here + // rather than anywhere further down — the reporter reaching this with a + // claim token must never see them. + .filter(|(message, ..)| staff || !message.internal) .map(|message| ApiTicketMessageOrAuditLog::Message { message: message.into(), }) @@ -153,7 +159,7 @@ pub async fn get_handler(req: Request) -> ApiResult { } }); - Ok((ticket, creator, events).into()) + Ok(ApiTicketOverview::new(ticket, creator, events)) } #[instrument(name = "POST /api/tickets", skip(req))] @@ -201,14 +207,15 @@ pub async fn post_handler(mut req: Request) -> ApiResult { } } - Ok( + Ok(ApiTicket::for_viewer( ( ticket, Some(user.clone()), vec![(message, Some(UserPublic::from(user)), vec![])], - ) - .into(), - ) + ), + // A ticket the caller just opened, containing only their own message. + false, + )) } #[instrument(name = "POST /api/tickets/:id", skip(req), fields(id))] @@ -230,6 +237,12 @@ pub async fn post_message_handler( return Err(ApiError::TicketMessageEmpty); } + // A note is only ever written by staff, and writing one as anybody else would + // put text on the ticket that its own reporter cannot see. + if new_message.internal && access != (TicketAccess::User { staff: true }) { + return Err(ApiError::ActorNotAuthorized); + } + let email_sender = req.data::>().unwrap(); let registry_url = req.data::().unwrap(); @@ -262,26 +275,31 @@ pub async fn post_message_handler( .await?; (message, None, vec![]) } - TicketAccess::User => { + TicketAccess::User { .. } => { let iam = req.iam(); let author = iam.check_current_user_access()?; - notify = match &creator { - // A message from staff on somebody's own ticket. - Some(creator) if creator.id != author.id => creator - .email - .clone() - .map(|email| (email, creator.name.clone())), - // A message on an unclaimed, email-opened ticket: staff replying to - // whoever wrote in. - None => ticket.reporter_email.clone().map(|email| { - let name = ticket - .reporter_name + notify = if new_message.internal { + // Nobody is told about a note. It exists only inside the ticket. + None + } else { + match &creator { + // A message from staff on somebody's own ticket. + Some(creator) if creator.id != author.id => creator + .email .clone() - .unwrap_or_else(|| email.clone()); - (email, name) - }), - // The ticket's owner talking; nothing to notify them of. - Some(_) => None, + .map(|email| (email, creator.name.clone())), + // A message on an unclaimed, email-opened ticket: staff replying to + // whoever wrote in. + None => ticket.reporter_email.clone().map(|email| { + let name = ticket + .reporter_name + .clone() + .unwrap_or_else(|| email.clone()); + (email, name) + }), + // The ticket's owner talking; nothing to notify them of. + Some(_) => None, + } }; let (message, user) = db @@ -355,7 +373,9 @@ pub async fn claim_handler(req: Request) -> ApiResult { .await? .ok_or(ApiError::TicketClaimTokenInvalid)?; - Ok(ticket.into()) + // The ticket has just become this person's own; they are reading it as its + // reporter, not as staff. + Ok(ApiTicket::for_viewer(ticket, false)) } #[instrument( diff --git a/api/src/api/types.rs b/api/src/api/types.rs index c8469d31b..d0781e327 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -1159,15 +1159,14 @@ pub struct ApiTicketOverview { pub created_at: DateTime, } -impl From<(Ticket, Option, Vec)> - for ApiTicketOverview -{ - fn from( - (value, user, events): ( - Ticket, - Option, - Vec, - ), +impl ApiTicketOverview { + /// Builds the response for a particular viewer. See + /// [`ApiTicket::for_viewer`] for why this is not a `From` impl; the events + /// passed in must already have been filtered for that viewer. + pub fn new( + value: Ticket, + user: Option, + events: Vec, ) -> Self { Self { id: value.id, @@ -1205,8 +1204,19 @@ pub struct ApiTicket { pub created_at: DateTime, } -impl From for ApiTicket { - fn from((value, user, messages): FullTicket) -> Self { +impl ApiTicket { + /// Builds the response for a particular viewer. + /// + /// Deliberately not a `From` impl. Staff notes live in the same message list + /// as the conversation, so every path that returns a ticket has to decide + /// whether the person reading is allowed to see them — and a conversion that + /// could be reached by `.into()` is one that will eventually be reached + /// without that decision being made. + pub fn for_viewer(full: FullTicket, viewer_is_staff: bool) -> Self { + let (value, user, messages) = full; + let messages = messages + .into_iter() + .filter(|(message, ..)| viewer_is_staff || !message.internal); Self { id: value.id, ticket_number: value.ticket_number, @@ -1221,7 +1231,7 @@ impl From for ApiTicket { subject: value.subject, meta: value.meta, status: value.status, - messages: messages.into_iter().map(|message| message.into()).collect(), + messages: messages.map(|message| message.into()).collect(), updated_at: value.updated_at, created_at: value.created_at, } @@ -1234,6 +1244,9 @@ pub struct ApiTicketMessage { pub id: Uuid, pub author: ApiTicketActor, pub direction: TicketMessageDirection, + /// A note staff wrote to each other. Only ever present for a staff viewer — + /// see [`ApiTicket::for_viewer`]. + pub internal: bool, pub message: String, pub attachments: Vec, pub updated_at: DateTime, @@ -1252,6 +1265,7 @@ impl From for ApiTicketMessage { ) .into(), direction: value.direction, + internal: value.internal, message: value.message, attachments: attachments.into_iter().map(Into::into).collect(), updated_at: value.updated_at, diff --git a/api/src/db/database.rs b/api/src/db/database.rs index 8c69a8672..223a32ace 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -4210,6 +4210,7 @@ gitlab_id: r.user_gitlab_id, author_email_verified: r.message_author_email_verified, direction: r.message_direction, email_message_id: r.message_email_message_id, + internal: r.message_internal, message: r.message_message, updated_at: r.message_updated_at, created_at: r.message_created_at, @@ -4776,39 +4777,56 @@ gitlab_id: r.user_gitlab_id, .fetch_one(&mut *tx) .await?; - let direction = if author_is_creator { + // A note is always from the JSR side, even when the staff member writing it + // is the person who opened the ticket — which happens whenever staff file + // one themselves. Treating it as inbound there would both misattribute it + // and violate the check constraint that ties internal to outbound. + let direction = if author_is_creator && !message.internal { TicketMessageDirection::Inbound } else { TicketMessageDirection::Outbound }; let message_id = sqlx::query_scalar!( - r#"INSERT INTO ticket_messages (ticket_id, author, direction, message, email_message_id) - VALUES ($1, $2, $3, $4, $5) + r#"INSERT INTO ticket_messages (ticket_id, author, direction, message, email_message_id, internal) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id"#, id as _, author as _, direction as _, message.message as _, email_message_id, + message.internal, ) .fetch_one(&mut *tx) .await?; - // Whoever did not just speak is the one now owed a response. - let status = if author_is_creator { - TicketStatus::WaitingOnSupport + if message.internal { + // A note is not a reply: nobody outside the team has seen it, so it + // cannot change who is owed a response. It is still activity on the + // ticket, so the timestamp moves. + sqlx::query!( + r#"UPDATE tickets SET updated_at = now() WHERE id = $1"#, + id as _, + ) + .execute(&mut *tx) + .await?; } else { - TicketStatus::WaitingOnUser - }; - sqlx::query!( - r#"UPDATE tickets SET status = $2, updated_at = now() - WHERE id = $1 AND status NOT IN ('closed', 'spam')"#, - id as _, - status as _, - ) - .execute(&mut *tx) - .await?; + // Whoever did not just speak is the one now owed a response. + let status = if author_is_creator { + TicketStatus::WaitingOnSupport + } else { + TicketStatus::WaitingOnUser + }; + sqlx::query!( + r#"UPDATE tickets SET status = $2, updated_at = now() + WHERE id = $1 AND status NOT IN ('closed', 'spam')"#, + id as _, + status as _, + ) + .execute(&mut *tx) + .await?; + } let messages = Self::load_ticket_messages(&mut tx, id).await?; diff --git a/api/src/db/sql_fragments.rs b/api/src/db/sql_fragments.rs index bf4bacff4..bdad5bbff 100644 --- a/api/src/db/sql_fragments.rs +++ b/api/src/db/sql_fragments.rs @@ -7,11 +7,11 @@ pub const USER_SELECT_FULL: &str = r#"id, name, email, avatar_url, updated_at, c (SELECT COUNT(created_at) FROM scopes WHERE creator = id) as "scope_usage!", (CASE WHEN users.is_staff THEN ( SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND ( - SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1 + SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1 ) = 'inbound' ) ELSE ( SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND ( - SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1 + SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1 ) = 'outbound' ) END) as "newer_ticket_messages_count!" "#; @@ -21,11 +21,11 @@ pub const USER_SELECT_FULL_RT: &str = r#"id, name, email, avatar_url, updated_at (SELECT COUNT(created_at) FROM scopes WHERE creator = id) as "scope_usage", (CASE WHEN users.is_staff THEN ( SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND ( - SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1 + SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1 ) = 'inbound' ) ELSE ( SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND ( - SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1 + SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1 ) = 'outbound' ) END) as "newer_ticket_messages_count" "#; @@ -125,11 +125,11 @@ pub const USER_SELECT_FULL_JOINED_OPTIONAL: &str = r#"users.id as "user_id?", us (SELECT COUNT(scopes.created_at) FROM scopes WHERE scopes.creator = users.id) as "user_scope_usage", (CASE WHEN users.is_staff THEN ( SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND ( - SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1 + SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1 ) = 'inbound' ) ELSE ( SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND ( - SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1 + SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1 ) = 'outbound' ) END) as "user_newer_ticket_messages_count""#; @@ -138,14 +138,14 @@ pub const USER_SELECT_FULL_JOINED_RT: &str = r#"users.id as "user_id", users.nam (SELECT COUNT(scopes.created_at) FROM scopes WHERE scopes.creator = users.id) as "user_scope_usage", (CASE WHEN users.is_staff THEN ( SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.status NOT IN ('closed', 'spam') AND ( - SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1 + SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1 ) = 'inbound' ) ELSE ( SELECT COUNT(tickets.created_at) FROM tickets WHERE tickets.creator = users.id AND tickets.status NOT IN ('closed', 'spam') AND ( - SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id ORDER BY tm.created_at DESC LIMIT 1 + SELECT tm.direction FROM ticket_messages as tm WHERE tm.ticket_id = tickets.id AND NOT tm.internal ORDER BY tm.created_at DESC LIMIT 1 ) = 'outbound' ) END) as "user_newer_ticket_messages_count""#; -pub const TICKET_MESSAGE_SELECT_JOINED: &str = r#"ticket_messages.id as "message_id", ticket_messages.ticket_id as "message_ticket_id", ticket_messages.author as "message_author", ticket_messages.author_email as "message_author_email", ticket_messages.author_name as "message_author_name", ticket_messages.author_email_verified as "message_author_email_verified", ticket_messages.direction as "message_direction: TicketMessageDirection", ticket_messages.email_message_id as "message_email_message_id", ticket_messages.message as "message_message", ticket_messages.updated_at as "message_updated_at", ticket_messages.created_at as "message_created_at""#; +pub const TICKET_MESSAGE_SELECT_JOINED: &str = r#"ticket_messages.id as "message_id", ticket_messages.ticket_id as "message_ticket_id", ticket_messages.author as "message_author", ticket_messages.author_email as "message_author_email", ticket_messages.author_name as "message_author_name", ticket_messages.author_email_verified as "message_author_email_verified", ticket_messages.direction as "message_direction: TicketMessageDirection", ticket_messages.email_message_id as "message_email_message_id", ticket_messages.internal as "message_internal", ticket_messages.message as "message_message", ticket_messages.updated_at as "message_updated_at", ticket_messages.created_at as "message_created_at""#; pub const AUDIT_LOG_SELECT_JOINED: &str = r#"audit_logs.actor_id as "audit_log_actor_id", audit_logs.is_sudo as "audit_log_is_sudo", audit_logs.action as "audit_log_action", audit_logs.meta as "audit_log_meta", audit_logs.created_at as "audit_log_created_at""#; diff --git a/crates/jsr_types/src/models.rs b/crates/jsr_types/src/models.rs index b2129115d..bd72d8fd3 100644 --- a/crates/jsr_types/src/models.rs +++ b/crates/jsr_types/src/models.rs @@ -1226,6 +1226,10 @@ impl FromRow<'_, sqlx::postgres::PgRow> for Ticket { #[derive(Debug, Deserialize)] pub struct NewTicketMessage { pub message: String, + /// Write this as a staff-only note rather than a reply. Rejected for anyone + /// who is not staff. + #[serde(default)] + pub internal: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1246,6 +1250,9 @@ pub struct TicketMessage { /// from, used to thread replies back onto the ticket. `None` for messages /// that were never emailed. pub email_message_id: Option, + /// A note staff wrote to each other. Never emailed, and never shown to the + /// person who opened the ticket. + pub internal: bool, pub message: String, pub updated_at: DateTime, pub created_at: DateTime, diff --git a/frontend/islands/TicketMessageInput.tsx b/frontend/islands/TicketMessageInput.tsx index 84392e0fe..ce17f62af 100644 --- a/frontend/islands/TicketMessageInput.tsx +++ b/frontend/islands/TicketMessageInput.tsx @@ -7,6 +7,10 @@ import type { NewTicketMessage, TicketStatus, } from "../utils/api_types.ts"; +import { + remainingPlaceholders, + TICKET_TEMPLATES, +} from "../utils/ticket_templates.ts"; import { api, path } from "../utils/api.ts"; import { useSignal } from "@preact/signals"; @@ -24,6 +28,10 @@ export function TicketMessageInput( const message = useSignal(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + /// Writing a note to the rest of the team rather than replying to the person + /// who opened the ticket. + const [internal, setInternal] = useState(false); + const [template, setTemplate] = useState(""); useEffect(() => { if (error) { @@ -37,10 +45,15 @@ export function TicketMessageInput( const closed = status === "closed"; const query = claimToken ? { claim: claimToken } : undefined; + // Only worth pointing out on a reply; a note is for the team and can say + // whatever it likes. + const placeholders = internal ? [] : remainingPlaceholders(message.value); + + const categories = [...new Set(TICKET_TEMPLATES.map((t) => t.category))]; return (
{ e.preventDefault(); @@ -54,6 +67,7 @@ export function TicketMessageInput( path`/tickets/${ticketId}`, { message: message.value, + internal, } satisfies NewTicketMessage, query, ).then((resp) => { @@ -68,13 +82,82 @@ export function TicketMessageInput( }); }} > + {isStaff && ( +
+ + +
+ + + +
+
+ )} +