diff --git a/Cargo.lock b/Cargo.lock index a40ee4719..bdfa1fb25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1056,9 +1056,9 @@ dependencies = [ [[package]] name = "deno_doc" -version = "0.205.0" +version = "0.206.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d80bf35d7150d36768fc1f4e6bb71444f39924263b71a343e1a7b4efa64b9d8" +checksum = "0a2af46876eb43b3334539f548c8c106f9ad86d330ca5661c04fcdb27fd8e463" dependencies = [ "anyhow", "cfg-if", 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/Cargo.toml b/api/Cargo.toml index b68ef389f..ea384b16f 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -98,7 +98,7 @@ async-compression = { version = "0.4", features = ["futures-io", "gzip"] } deno_graph = "=0.110.2" deno_ast = { version = "0.53.0", features = ["view"] } # sync with frontend/deno.json -deno_doc = { version = "=0.205.0", features = ["comrak"] } +deno_doc = { version = "=0.206.0", features = ["comrak"] } deno_error = "0.7.0" comrak = { version = "0.29.0", default-features = false } ammonia = "4.0.0" 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/analysis.rs b/api/src/analysis.rs index a34a85e40..4350f0bd2 100644 --- a/api/src/analysis.rs +++ b/api/src/analysis.rs @@ -234,7 +234,7 @@ async fn analyze_package_inner( .await .map_err(PublishError::NpmTarballError)?; - let (meta, readme_path) = { + let (mut meta, readme_path) = { let readme = files .iter() .find(|file| file.0.case_insensitive().is_readme()); @@ -273,9 +273,13 @@ async fn analyze_package_inner( workerd: None, bun: None, }, + // only the search index is taken from this ctx, so usage instructions + // don't matter here + false, registry_url.to_string(), None, ); + meta.symbol_count = Some(count_symbols(&ctx)); let search_index = deno_doc::html::generate_search_index(&ctx); let doc_search_json = if let serde_json::Value::Object(mut obj) = search_index { @@ -345,9 +349,41 @@ fn generate_score( ), all_fast_check, has_provenance: false, // Provenance score is updated after version publish + // filled in once the render context exists, see `count_symbols` + symbol_count: None, } } +/// Counts the symbols the generated docs actually list for a package: every +/// exported symbol of every entrypoint, plus namespace members, minus the ones +/// hidden from the listings (`@internal` and non-exported types). +/// +/// Deduplicated per `(file, qualified name)` the same way the search index is, +/// so a symbol re-exported from several entrypoints counts once per entrypoint +/// it is documented under, and never twice within one. Class/interface members +/// are not counted: they are part of their parent symbol, not separate entries +/// in the listings. +fn count_symbols(ctx: &deno_doc::html::GenerateCtx) -> u32 { + let mut seen = HashSet::new(); + + for (short_path, nodes) in &ctx.doc_nodes { + for node in deno_doc::html::partition::flatten_namespace( + ctx, + nodes.iter().map(std::borrow::Cow::Borrowed), + ) { + if node.is_internal(ctx) { + continue; + } + seen.insert(( + short_path.path.clone(), + node.get_qualified_name().to_string(), + )); + } + } + + seen.len() as u32 +} + /// Cap on how many undocumented entrypoints are recorded in /// `PackageVersionMeta`. The full list is unbounded (one entry per export) and /// `meta` is stored in the database and returned in API responses, so a diff --git a/api/src/api.yml b/api/src/api.yml index 6df968203..1fa2dbbe5 100644 --- a/api/src/api.yml +++ b/api/src/api.yml @@ -2583,6 +2583,14 @@ components: type: string enum: ["readme", "jsdoc"] description: The source of the package readme. + symbolCount: + type: integer + nullable: true + description: >- + The number of symbols documented by the latest version of the + package. Null for packages with no published version, and for + packages whose latest version was published before this count was + recorded. required: - scope - name @@ -2751,6 +2759,12 @@ components: type: string format: date-time description: The date and time when the package version was last updated. + symbolCount: + type: integer + nullable: true + description: >- + The number of symbols documented by this version. Null for versions + published before this count was recorded. required: - scope - package @@ -2812,6 +2826,12 @@ components: type: string format: date-time description: The date and time when the package version was last updated. + symbolCount: + type: integer + nullable: true + description: >- + The number of symbols documented by this version. Null for versions + published before this count was recorded. required: - scope - package 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/package.rs b/api/src/api/package.rs index d42b7941b..11a025496 100644 --- a/api/src/api/package.rs +++ b/api/src/api/package.rs @@ -1787,18 +1787,29 @@ pub async fn get_source_handler( None }; - let path_buf = std::path::PathBuf::from(path); + let path_buf = std::path::PathBuf::from(&path); let source = if let Some(file) = file { let size = file.len(); - let highlighter = deno_doc::html::comrak::ComrakHighlightWrapperAdapter( - Some(Arc::new(crate::tree_sitter::ComrakAdapter { - show_line_numbers: true, - })), - ); - let view = if let Ok(file) = String::from_utf8(file.to_vec()) { + let links = source_specifier_links( + &scope, + &package, + &version.version, + &path, + &file, + buckets, + req.data::().unwrap().0.as_str(), + ) + .await; + + let mut adapter = crate::tree_sitter::ComrakAdapter::new(true); + adapter.links = links; + let highlighter = deno_doc::html::comrak::ComrakHighlightWrapperAdapter( + Some(Arc::new(adapter)), + ); + let mut out = vec![]; highlighter.write_pre_tag(&mut out, Default::default())?; highlighter.write_code_tag(&mut out, Default::default())?; @@ -1905,6 +1916,70 @@ pub async fn get_source_handler( }) } +/// Finds the import/export specifiers of the file being viewed, so the +/// highlighter can render them as links (jsr-io/jsr#17). +/// +/// The ranges come from the `moduleGraph2` recorded at publish time, so +/// nothing is re-parsed here. Links are a nicety: anything missing or +/// unreadable just yields a file view without them. +async fn source_specifier_links( + scope: &ScopeName, + package: &PackageName, + version: &Version, + path: &str, + source: &str, + buckets: &Buckets, + registry_url: &str, +) -> Vec { + if !crate::source_links::is_module_path(path) { + return Vec::new(); + } + + let metadata_path = + crate::s3_paths::version_metadata(scope, package, version); + let metadata = + match buckets.modules_bucket.download(metadata_path.into()).await { + Ok(Some(bytes)) => { + match serde_json::from_slice::(&bytes) + { + Ok(metadata) => metadata, + Err(err) => { + error!("failed to parse version metadata for links: {err}"); + return Vec::new(); + } + } + } + Ok(None) => return Vec::new(), + Err(err) => { + error!("failed to download version metadata for links: {err}"); + return Vec::new(); + } + }; + + let Some(module_info) = metadata.module_graph_2.get(path) else { + return Vec::new(); + }; + + let files = metadata + .manifest + .keys() + .map(|path| path.to_string()) + .collect(); + + crate::source_links::specifier_links( + module_info, + source, + &crate::source_links::LinkContext { + scope, + package, + version, + current_path: path, + files: &files, + registry_url, + }, + ) +} + #[instrument( name = "GET /api/scopes/:scope/packages/:package/diff/:old_version/:new_version", skip(req), @@ -2046,6 +2121,8 @@ pub async fn get_diff_handler( repo, false, package.runtime_compat, + // diff pages don't render usage instructions at all + false, registry_url, Some((diff, full)), ); 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..c335aa8b9 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -464,6 +464,9 @@ pub struct ApiPackage { pub when_featured: Option>, pub is_archived: bool, pub readme_source: ApiReadmeSource, + /// Symbols documented by the latest version, or `None` for packages whose + /// latest version predates the count being recorded. + pub symbol_count: Option, } impl From for ApiPackage { @@ -487,6 +490,12 @@ impl From for ApiPackage { .latest_version .as_ref() .map(|_| score.score_percentage()), + // a package with no published version has no docs to count symbols in + symbol_count: if package.latest_version.is_some() { + meta.symbol_count + } else { + None + }, latest_version: package.latest_version, when_featured: package.when_featured, is_archived: package.is_archived, @@ -631,6 +640,9 @@ pub struct ApiPackageVersion { pub readme_path: Option, pub updated_at: DateTime, pub created_at: DateTime, + /// Symbols documented by this specific version, or `None` for versions + /// published before the count was recorded. + pub symbol_count: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -686,6 +698,7 @@ impl From for ApiPackageVersion { readme_path: value.readme_path, updated_at: value.updated_at, created_at: value.created_at, + symbol_count: value.meta.symbol_count, } } } @@ -704,6 +717,7 @@ impl From for ApiPackageVersion { readme_path: value.readme_path, updated_at: value.updated_at, created_at: value.created_at, + symbol_count: value.meta.symbol_count, } } } @@ -1159,15 +1173,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 +1218,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 +1245,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 +1258,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 +1279,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/api/src/docs.rs b/api/src/docs.rs index 69768e22f..197833053 100644 --- a/api/src/docs.rs +++ b/api/src/docs.rs @@ -264,6 +264,7 @@ impl GenerateCtxCache { github_repository, has_readme, runtime_compat, + has_create_export(exports), registry_url.to_string(), None, ); @@ -287,8 +288,12 @@ lazy_static::lazy_static! { let mut ammonia_builder = ammonia::Builder::default(); ammonia_builder - .add_tags(["video", "button", "svg", "path", "rect", "section"]) + .add_tags([ + "video", "button", "svg", "path", "rect", "section", "g", "defs", + "clipPath", + ]) .add_generic_attributes(["id", "align"]) + .add_tag_attributes("g", ["clip-path", "fill", "opacity", "transform"]) .add_tag_attributes("button", ["data-copy"]) .add_tag_attributes( "svg", @@ -325,7 +330,15 @@ lazy_static::lazy_static! { // comrak footnote output .add_allowed_classes("section", ["footnotes"]) .add_allowed_classes("sup", ["footnote-ref"]) - .add_allowed_classes("a", ["footnote-backref"]) + .add_allowed_classes("a", ["footnote-backref", "anchor"]) + // deno_doc heading permalinks + .add_tag_attributes("a", ["aria-label", "tabindex"]) + .add_allowed_classes("h1", ["anchorable"]) + .add_allowed_classes("h2", ["anchorable"]) + .add_allowed_classes("h3", ["anchorable"]) + .add_allowed_classes("h4", ["anchorable"]) + .add_allowed_classes("h5", ["anchorable"]) + .add_allowed_classes("h6", ["anchorable"]) .add_allowed_classes( "div", [ @@ -571,6 +584,13 @@ pub struct DocsInfo { pub rewrite_map: IndexMap, } +/// Whether `deno create @scope/package` works for this package: the CLI +/// rewrites that into `jsr:@scope/package/create`, so it needs a `./create` +/// export to run (jsr-io/jsr#1358). +pub fn has_create_export(exports: &crate::db::ExportsMap) -> bool { + exports.contains_key("./create") +} + pub fn get_docs_info( exports: &crate::db::ExportsMap, entrypoint: Option<&str>, @@ -684,9 +704,7 @@ pub fn render_markdown_file( path: &str, ) -> Option { let renderer = deno_doc::html::comrak::create_renderer( - Some(Arc::new(super::tree_sitter::ComrakAdapter { - show_line_numbers: false, - })), + Some(Arc::new(super::tree_sitter::ComrakAdapter::new(false))), Some(Box::new(match_node_value)), Some(Box::new(|html| AMMONIA.clean(&html).to_string())), ); @@ -755,6 +773,7 @@ const SYMBOL_LISTING_LIMIT: usize = 2048; version_is_latest, has_readme, runtime_compat, + has_create_export, registry_url, diff ) @@ -771,6 +790,7 @@ pub fn get_generate_ctx( github_repository: Option, has_readme: bool, runtime_compat: RuntimeCompat, + has_create_export: bool, registry_url: String, diff: Option<(deno_doc::diff::DocDiff, bool)>, ) -> GenerateCtx { @@ -781,9 +801,7 @@ pub fn get_generate_ctx( get_url_rewriter(url_rewriter_base, github_repository, has_readme); let markdown_renderer = deno_doc::html::comrak::create_renderer( - Some(Arc::new(super::tree_sitter::ComrakAdapter { - show_line_numbers: false, - })), + Some(Arc::new(super::tree_sitter::ComrakAdapter::new(false))), Some(Box::new(match_node_value)), Some(Box::new(|html| AMMONIA.clean(&html).to_string())), ); @@ -848,6 +866,7 @@ pub fn get_generate_ctx( package, version, version_is_latest, + has_create_export, }) as Arc }), rewrite_map: Some(rewrite_map), @@ -1069,13 +1088,16 @@ fn generate_symbol_page( let doc_nodes = 'outer: loop { let next_part = name_parts.next()?; + // Symbols that aren't exported get a page too: the public API refers to + // them, so their signatures link here and this page is what those links + // point at (jsr-io/jsr#165). deno_doc keeps them out of the listings and + // of symbol search, since they can't be imported. + // + // `@internal` is different: the author asked for the symbol to be hidden, + // so it gets no page, and deno_doc emits no link to one either. let mut nodes = doc_nodes .iter() - .filter(|node| { - !node.declarations.iter().all(|decl| { - decl.declaration_kind == deno_doc::node::DeclarationKind::Private - }) && node.get_name() == next_part - }) + .filter(|node| !node.is_hidden() && node.get_name() == next_part) .flat_map(|node| resolve_doc_node_references(ctx, node.clone())) .collect::>(); @@ -1517,6 +1539,10 @@ struct DocUsageComposer { package: PackageName, version: Version, version_is_latest: bool, + /// Whether the package has a `./create` export. `deno create @scope/pkg` + /// runs `jsr:@scope/pkg/create`, so that export is exactly what makes the + /// command work (jsr-io/jsr#1358). + has_create_export: bool, } impl deno_doc::html::UsageComposer for DocUsageComposer { @@ -1571,12 +1597,20 @@ impl deno_doc::html::UsageComposer for DocUsageComposer { ); if !self.runtime_compat.deno.is_some_and(|compat| !compat) { + // Packages exporting `./create` are project scaffolds, so lead with the + // command that actually runs them. + let scaffold = if self.has_create_export { + format!("Scaffold a project\n```\ndeno create {scoped_name}\n```\n") + } else { + String::new() + }; + map.insert( UsageComposerEntry { name: "Deno".to_string(), icon: Some("/logos/deno.svg".into()), }, - format!("Add Package\n```\ndeno add jsr:{scoped_name}\n```{import}\n
or
\n\nImport directly with a jsr specifier\n{}\n", usage_to_md(&format!("jsr:{jsr_url}"), Some(self.package.as_str()))), + format!("{scaffold}Add Package\n```\ndeno add jsr:{scoped_name}\n```{import}\n
or
\n\nImport directly with a jsr specifier\n{}\n", usage_to_md(&format!("jsr:{jsr_url}"), Some(self.package.as_str()))), ); } @@ -1636,6 +1670,46 @@ mod tests { use super::*; use deno_doc::html::ShortPath; + #[test] + fn renders_heading_permalinks_through_the_sanitizer() { + // deno_doc emits heading permalinks as an `anchorable` heading wrapping an + // `anchor` link around its bundled link.svg. The allowlist has to keep the + // classes the CSS hangs off of, and it has to keep / so the + // clip rect stays inside a definition instead of painting over the icon + // (#1530). + let html = render_markdown_file( + "## Install\n", + &ScopeName::new("scope".to_string()).unwrap(), + &PackageName::new("pkg".to_string()).unwrap(), + &Version::new("1.0.0").unwrap(), + None, + "/README.md", + ) + .unwrap(); + + assert!( + html.contains(r#"

"#), + "{html}" + ); + assert!(html.contains(r##"href="#install""##), "{html}"); + assert!(html.contains(r#"class="anchor""#), "{html}"); + assert!(html.contains(r#"aria-label="Anchor""#), "{html}"); + assert!(html.contains(r#"tabindex="-1""#), "{html}"); + // the icon's clip plumbing survives, so the white rect stays unpainted + assert!(html.contains(""), "{html}"); + assert!(html.contains("") + .next() + .unwrap() + .contains(""), + "the clip rect escaped its : {html}" + ); + } + #[test] fn render_markdown_file_test() { let html = render_markdown_file( @@ -1966,4 +2040,71 @@ mod tests { // Protocol-relative URLs are left untouched. assert_eq!(rewriter(None, "//example.com/x"), "//example.com/x"); } + + fn compose_deno_usage(has_create_export: bool) -> String { + use deno_doc::html::UsageComposer; + + let composer = DocUsageComposer { + runtime_compat: RuntimeCompat { + browser: None, + deno: None, + node: None, + workerd: None, + bun: None, + }, + scope: ScopeName::new("foo".to_string()).unwrap(), + package: PackageName::new("bar".to_string()).unwrap(), + version: Version::new("1.0.0").unwrap(), + version_is_latest: true, + has_create_export, + }; + + let usage_to_md = |url: &str, _name: Option<&str>| format!("`{url}`"); + let usages = composer.compose(UrlResolveKind::Root, &usage_to_md); + + usages + .into_iter() + .find(|(entry, _)| entry.name == "Deno") + .unwrap() + .1 + } + + #[test] + fn create_export_adds_scaffold_command() { + let with_create = compose_deno_usage(true); + assert!( + with_create.contains("deno create @foo/bar"), + "{with_create}" + ); + // the scaffold block leads, but installing is still offered + assert!( + with_create.find("deno create @foo/bar").unwrap() + < with_create.find("deno add jsr:@foo/bar").unwrap(), + "{with_create}" + ); + + // a package without the export must not advertise a command that fails + let without_create = compose_deno_usage(false); + assert!(!without_create.contains("deno create"), "{without_create}"); + assert!( + without_create.contains("deno add jsr:@foo/bar"), + "{without_create}" + ); + } + + #[test] + fn has_create_export_checks_the_export_key() { + let with_create = crate::db::ExportsMap::new(IndexMap::from([ + (".".to_string(), "./mod.ts".to_string()), + ("./create".to_string(), "./create.ts".to_string()), + ])); + assert!(has_create_export(&with_create)); + + // a `create` *file* under some other export must not count + let without_create = crate::db::ExportsMap::new(IndexMap::from([ + (".".to_string(), "./mod.ts".to_string()), + ("./cli".to_string(), "./create.ts".to_string()), + ])); + assert!(!has_create_export(&without_create)); + } } diff --git a/api/src/main.rs b/api/src/main.rs index 11b716447..04c475a72 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -23,6 +23,7 @@ mod publish; mod s3; mod s3_paths; mod sitemap; +mod source_links; mod tarball; mod task_queue; mod tasks; diff --git a/api/src/source_links.rs b/api/src/source_links.rs new file mode 100644 index 000000000..d1e1d559b --- /dev/null +++ b/api/src/source_links.rs @@ -0,0 +1,354 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +//! Turns the import/export specifiers recorded in a version's module graph +//! into clickable links in the file view (jsr-io/jsr#17). +//! +//! Every published version stores a `moduleGraph2` in its metadata, and each +//! entry already carries the source range of every specifier it mentions — +//! static and dynamic imports, `export ... from`, triple-slash references, the +//! JSX import source pragma and JSDoc type imports. So the file view does not +//! have to re-parse anything: it maps those ranges onto byte offsets in the +//! file it is about to highlight and hands them to the highlighter. + +use std::collections::HashSet; + +use deno_graph::Position; +use deno_graph::PositionRange; +use deno_graph::analysis::DependencyDescriptor; +use deno_graph::analysis::DynamicArgument; +use deno_graph::analysis::ModuleInfo; +use deno_graph::analysis::TypeScriptReference; +use deno_semver::RangeSetOrTag; +use url::Url; + +use crate::ids::PackageName; +use crate::ids::ScopeName; +use crate::ids::Version; +use crate::tree_sitter::SourceLink; + +/// Everything needed to turn a specifier into a URL on this site. +pub struct LinkContext<'a> { + pub scope: &'a ScopeName, + pub package: &'a PackageName, + pub version: &'a Version, + /// Path of the file being viewed, e.g. `/src/mod.ts`. + pub current_path: &'a str, + /// Paths of the files in this version, used so a relative specifier only + /// becomes a link when it actually resolves to a file we can show. + pub files: &'a HashSet, + pub registry_url: &'a str, +} + +/// Collects the linkable specifiers of one module, sorted by position and with +/// overlaps removed so the highlighter can walk them in one pass. +pub fn specifier_links( + module_info: &ModuleInfo, + source: &str, + ctx: &LinkContext, +) -> Vec { + let lines = LineIndex::new(source); + let mut links = Vec::new(); + + let mut push = |specifier: &str, range: &PositionRange| { + let Some(href) = resolve_href(specifier, ctx) else { + return; + }; + let Some(range) = lines.byte_range(range) else { + return; + }; + if range.is_empty() { + return; + } + links.push(SourceLink { range, href }); + }; + + for dependency in &module_info.dependencies { + match dependency { + DependencyDescriptor::Static(dependency) => { + push(&dependency.specifier, &dependency.specifier_range); + if let Some(types) = &dependency.types_specifier { + push(&types.text, &types.range); + } + } + DependencyDescriptor::Dynamic(dependency) => { + // only a dynamic import with a plain string argument has a specifier + // to link; templates and computed expressions do not + if let DynamicArgument::String(specifier) = &dependency.argument { + push(specifier, &dependency.argument_range); + } + if let Some(types) = &dependency.types_specifier { + push(&types.text, &types.range); + } + } + } + } + + for reference in &module_info.ts_references { + let specifier = match reference { + TypeScriptReference::Path(specifier) => specifier, + TypeScriptReference::Types { specifier, .. } => specifier, + }; + push(&specifier.text, &specifier.range); + } + + for specifier in [ + module_info.self_types_specifier.as_ref(), + module_info.jsx_import_source.as_ref(), + module_info.jsx_import_source_types.as_ref(), + ] + .into_iter() + .flatten() + { + push(&specifier.text, &specifier.range); + } + + for import in &module_info.jsdoc_imports { + push(&import.specifier.text, &import.specifier.range); + } + + links.sort_by_key(|link| link.range.start); + // ranges from different sources can name the same specifier (a `@ts-types` + // override sitting on the import it overrides, say); keep the first + links.dedup_by(|later, earlier| later.range.start < earlier.range.end); + links +} + +/// Whether a file is one the module graph could have an entry for. Saves +/// fetching the version metadata when viewing a README or a JSON file. +pub fn is_module_path(path: &str) -> bool { + matches!( + path.rsplit_once('.').map(|(_, extension)| extension), + Some("js" | "mjs" | "cjs" | "jsx" | "ts" | "mts" | "cts" | "tsx") + ) +} + +fn resolve_href(specifier: &str, ctx: &LinkContext) -> Option { + if specifier.starts_with('.') { + return resolve_relative_href(specifier, ctx); + } + + let url = Url::parse(specifier).ok()?; + match url.scheme() { + "node" => Some(format!("https://nodejs.org/api/{}.html", url.path())), + "npm" => { + let reference = + deno_semver::npm::NpmPackageReqReference::from_str(specifier).ok()?; + let req = reference.req(); + Some(format!( + "https://www.npmjs.com/package/{}{}", + req.name, + match req.version_req.inner() { + RangeSetOrTag::RangeSet(_) => String::new(), + RangeSetOrTag::Tag(tag) => format!("/v/{tag}"), + }, + )) + } + "jsr" => { + let reference = + deno_semver::jsr::JsrPackageReqReference::from_str(specifier).ok()?; + let req = reference.req(); + + // link to the exact version when the specifier pins one, so the reader + // lands on the code that is actually imported + let version = req + .version_req + .range() + .and_then(|range| Version::new(&range.to_string()).ok()) + .map(|version| format!("@{version}")) + .unwrap_or_default(); + + Some(format!("/{}{version}", req.name)) + } + "http" | "https" if specifier.starts_with(ctx.registry_url) => { + // a direct registry URL, e.g. https://jsr.io/@scope/name/1.2.3/mod.ts + let parts = url.path().splitn(4, '/').collect::>(); + let [_, scope, package, rest] = parts[..] else { + return None; + }; + Some(format!("/{scope}/{package}@{rest}")) + } + "http" | "https" => Some(url.to_string()), + // `bun:`, `cloudflare:`, `virtual:` and friends have nothing to point at + _ => None, + } +} + +fn resolve_relative_href(specifier: &str, ctx: &LinkContext) -> Option { + let base = Url::parse(&format!("file://{}", ctx.current_path)).ok()?; + let resolved = base.join(specifier).ok()?; + let path = resolved.path(); + + // don't offer a link the file view would 404 on: a specifier can point + // outside the package, or rely on resolution jsr does not replay here + if !ctx.files.contains(path) { + return None; + } + + Some(format!( + "/@{}/{}/{}{path}", + ctx.scope, ctx.package, ctx.version + )) +} + +/// Maps the module graph's `(line, character)` positions onto byte offsets. +/// +/// `character` counts characters, not bytes and not UTF-16 units, so lines +/// with multi-byte characters before a specifier are walked by character. +struct LineIndex<'a> { + source: &'a str, + starts: Vec, +} + +impl<'a> LineIndex<'a> { + fn new(source: &'a str) -> Self { + let mut starts = vec![0]; + starts.extend(source.match_indices('\n').map(|(index, _)| index + 1)); + Self { source, starts } + } + + fn byte_offset(&self, position: &Position) -> Option { + let start = *self.starts.get(position.line)?; + let end = self + .starts + .get(position.line + 1) + .copied() + .unwrap_or(self.source.len()); + let line = &self.source[start..end]; + + let offset = line + .char_indices() + .nth(position.character) + .map(|(offset, _)| offset) + .unwrap_or(line.len()); + Some(start + offset) + } + + fn byte_range( + &self, + range: &PositionRange, + ) -> Option> { + let start = self.byte_offset(&range.start)?; + let end = self.byte_offset(&range.end)?; + (start <= end && end <= self.source.len()).then_some(start..end) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Resolves a specifier as if viewing `/src/mod.ts` of `@scope/pkg@1.2.3`, + /// with `files` as the package's file list. + fn resolve(specifier: &str, files: &[&str]) -> Option { + let scope = ScopeName::new("scope".to_string()).unwrap(); + let package = PackageName::new("pkg".to_string()).unwrap(); + let version = Version::new("1.2.3").unwrap(); + let files: HashSet = + files.iter().map(|path| path.to_string()).collect(); + + resolve_href( + specifier, + &LinkContext { + scope: &scope, + package: &package, + version: &version, + current_path: "/src/mod.ts", + files: &files, + registry_url: "https://jsr.io", + }, + ) + } + + const FILES: &[&str] = &["/src/mod.ts", "/src/util.ts", "/other.ts"]; + + #[test] + fn line_index_handles_multi_byte_characters() { + // `β` is two bytes, so the character index and the byte offset differ + let source = "const β = 1;\nimport x from \"./a.ts\";\n"; + let index = LineIndex::new(source); + + assert_eq!(index.byte_offset(&Position::new(0, 6)), Some(6)); + // after the two-byte β, character 7 is byte 8 + assert_eq!(index.byte_offset(&Position::new(0, 7)), Some(8)); + // line 1 starts right after the newline + assert_eq!(index.byte_offset(&Position::new(1, 0)), Some(14)); + assert_eq!( + &source[index.byte_offset(&Position::new(1, 14)).unwrap()..], + "\"./a.ts\";\n" + ); + // out of range lines resolve to nothing rather than panicking + assert_eq!(index.byte_offset(&Position::new(9, 0)), None); + } + + #[test] + fn relative_specifiers_link_to_the_file_view() { + assert_eq!( + resolve("./util.ts", FILES).as_deref(), + Some("/@scope/pkg/1.2.3/src/util.ts") + ); + assert_eq!( + resolve("../other.ts", FILES).as_deref(), + Some("/@scope/pkg/1.2.3/other.ts") + ); + // a specifier that does not name a file in the package is left alone, + // rather than becoming a link to a 404 + assert_eq!(resolve("./missing.ts", FILES), None); + assert_eq!(resolve("../../escape.ts", FILES), None); + } + + #[test] + fn external_specifiers_link_off_site() { + assert_eq!( + resolve("node:fs", FILES).as_deref(), + Some("https://nodejs.org/api/fs.html") + ); + assert_eq!( + resolve("npm:chalk@5", FILES).as_deref(), + Some("https://www.npmjs.com/package/chalk") + ); + assert_eq!( + resolve("jsr:@std/assert@^1.0.0", FILES).as_deref(), + Some("/@std/assert") + ); + assert_eq!( + resolve("jsr:@std/assert@1.0.2", FILES).as_deref(), + Some("/@std/assert@1.0.2") + ); + assert_eq!( + resolve("jsr:@std/assert@1.0.2/equals", FILES).as_deref(), + Some("/@std/assert@1.0.2") + ); + assert_eq!( + resolve("https://deno.land/x/foo/mod.ts", FILES).as_deref(), + Some("https://deno.land/x/foo/mod.ts") + ); + // registry URLs point back into the site + assert_eq!( + resolve("https://jsr.io/@std/assert/1.0.2/mod.ts", FILES).as_deref(), + Some("/@std/assert@1.0.2/mod.ts") + ); + // schemes with nowhere to go stay plain text + assert_eq!(resolve("bun:test", FILES), None); + assert_eq!(resolve("cloudflare:workers", FILES), None); + assert_eq!(resolve("lodash", FILES), None); + } + + #[test] + fn is_module_path_covers_the_view() { + for path in [ + "/mod.ts", + "/mod.tsx", + "/a.mts", + "/a.cts", + "/a.js", + "/a.mjs", + "/a.cjs", + "/a.jsx", + "/types.d.ts", + ] { + assert!(is_module_path(path), "{path}"); + } + for path in ["/README.md", "/deno.json", "/a.wasm", "/a.css"] { + assert!(!is_module_path(path), "{path}"); + } + } +} diff --git a/api/src/tree_sitter.rs b/api/src/tree_sitter.rs index 4910aee94..4006ff776 100644 --- a/api/src/tree_sitter.rs +++ b/api/src/tree_sitter.rs @@ -1,13 +1,38 @@ // Copyright 2024 the JSR authors. All rights reserved. MIT license. use std::collections::HashMap; use std::io::Write; +use std::ops::Range; use std::sync::OnceLock; use tree_sitter_highlight::Highlight; use tree_sitter_highlight::HighlightConfiguration; +use tree_sitter_highlight::HighlightEvent; + +/// A span of the highlighted source that should be rendered as a link, used by +/// the file view to make import/export specifiers clickable (jsr-io/jsr#17). +#[derive(Debug, Clone)] +pub struct SourceLink { + /// Byte range in the source being highlighted, including the quotes around + /// the specifier. + pub range: Range, + pub href: String, +} pub struct ComrakAdapter { pub show_line_numbers: bool, + /// Ranges to wrap in anchors, sorted by start and non-overlapping. Markdown + /// code fences pass none: their offsets are fence-relative, and only the + /// file view knows what a specifier resolves to. + pub links: Vec, +} + +impl ComrakAdapter { + pub fn new(show_line_numbers: bool) -> Self { + Self { + show_line_numbers, + links: Vec::new(), + } + } } impl comrak::adapters::SyntaxHighlighterAdapter for ComrakAdapter { @@ -29,16 +54,13 @@ impl comrak::adapters::SyntaxHighlighterAdapter for ComrakAdapter { .highlight(config, source, None, |e| tree_sitter_language_cb(e)); match res { - Ok(highlighter) => { - let mut renderer = tree_sitter_highlight::HtmlRenderer::new(); - match renderer - .render(highlighter, source, &|highlight| classes(highlight)) - { - Ok(()) => { + Ok(events) => { + match render_lines(events, source, &self.links) { + Ok(rendered) => { let mut line_numbers = String::new(); let mut lines = String::new(); - for (i, line) in renderer.lines().enumerate() { + for (i, line) in rendered.iter().enumerate() { let n = i + 1; if self.show_line_numbers { @@ -135,6 +157,147 @@ pub(crate) fn classes(highlight: Highlight) -> &'static [u8] { CLASSES_ATTRIBUTES[highlight.0].as_bytes() } +const fn html_escape(c: u8) -> Option<&'static str> { + match c { + b'>' => Some(">"), + b'<' => Some("<"), + b'&' => Some("&"), + b'\'' => Some("'"), + b'"' => Some("""), + _ => None, + } +} + +/// Escapes into a byte buffer rather than a `String` so multi-byte characters +/// survive: the source is UTF-8 and is appended byte by byte. +fn push_escaped(out: &mut Vec, text: &[u8]) { + for &c in text { + // carriage returns are dropped, matching `HtmlRenderer`, so CRLF sources + // don't render a stray character at the end of every line + if c == b'\r' { + continue; + } + match html_escape(c) { + Some(escape) => out.extend_from_slice(escape.as_bytes()), + None => out.push(c), + } + } +} + +/// Renders the highlighter's event stream to one HTML string per source line, +/// wrapping `links` in anchors along the way. +/// +/// This replaces `tree_sitter_highlight::HtmlRenderer`, which cannot emit +/// anchors. With an empty `links` it produces byte-identical output (see +/// `matches_upstream_renderer`). +/// +/// An anchor never spans a highlight boundary or a line break: a link that +/// covers several highlight spans becomes several anchors pointing at the same +/// href, which keeps the markup well-formed and every part of it clickable. +fn render_lines( + events: impl Iterator>, + source: &[u8], + links: &[SourceLink], +) -> Result, tree_sitter_highlight::Error> { + fn open_span(out: &mut Vec, highlight: Highlight) { + out.extend_from_slice(b"'); + } + + let mut lines: Vec = Vec::new(); + let mut line: Vec = Vec::new(); + let mut highlights: Vec = Vec::new(); + + for event in events { + match event? { + HighlightEvent::HighlightStart(highlight) => { + highlights.push(highlight); + open_span(&mut line, highlight); + } + HighlightEvent::HighlightEnd => { + highlights.pop(); + line.extend_from_slice(b""); + } + HighlightEvent::Source { start, end } => { + let mut pos = start; + while pos < end { + // the chunk runs until whichever comes first: the end of the link we + // are inside, the start of the next one, the end of the event, or a + // line break + let link = links.iter().find(|link| link.range.contains(&pos)); + let mut next = end; + if let Some(link) = link { + next = next.min(link.range.end); + } else if let Some(link) = + links.iter().find(|link| link.range.start > pos) + { + next = next.min(link.range.start); + } + let chunk_end = + match source[pos..next].iter().position(|byte| *byte == b'\n') { + Some(offset) => pos + offset + 1, + None => next, + }; + + let (text, ends_line) = match source[pos..chunk_end].split_last() { + Some((b'\n', rest)) => (rest, true), + _ => (&source[pos..chunk_end], false), + }; + + if let Some(link) = link { + // `no_color` keeps the syntax highlighting of the specifier; the + // stylesheet gives these an underline on hover instead + line.extend_from_slice( + br#""#); + } + push_escaped(&mut line, text); + if link.is_some() { + line.extend_from_slice(b""); + } + + if ends_line { + // close every open highlight so each line is standalone markup, + // then reopen them on the next one + for _ in &highlights { + line.extend_from_slice(b""); + } + line.push(b'\n'); + lines.push(finish_line(std::mem::take(&mut line))); + for highlight in &highlights { + open_span(&mut line, *highlight); + } + } + + pos = chunk_end; + } + } + } + } + + // a source that does not end in a newline still gets a final line; one that + // does must not gain an empty one + if !line.is_empty() { + line.push(b'\n'); + lines.push(finish_line(line)); + } + + Ok(lines) +} + +/// The highlighter only ever hands us whole code points and the markup we add +/// is ASCII, so this is infallible in practice; `from_utf8_lossy` keeps a +/// pathological input from taking the request down. +fn finish_line(line: Vec) -> String { + match String::from_utf8(line) { + Ok(line) => line, + Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(), + } +} + pub fn tree_sitter_language_cb( lang: &str, ) -> Option<&'static HighlightConfiguration> { @@ -451,9 +614,7 @@ mod tests { use comrak::adapters::SyntaxHighlighterAdapter; fn highlight(lang: &str, code: &str) -> String { - let adapter = ComrakAdapter { - show_line_numbers: false, - }; + let adapter = ComrakAdapter::new(false); let mut out = Vec::new(); adapter .write_highlighted(&mut out, Some(lang), code) @@ -479,4 +640,135 @@ mod tests { assert!(html.contains(" Vec { + let config = tree_sitter_language_cb(lang).unwrap(); + let source = code.as_bytes(); + let mut highlighter = tree_sitter_highlight::Highlighter::new(); + #[allow(clippy::redundant_closure)] + let events = highlighter + .highlight(config, source, None, |e| tree_sitter_language_cb(e)) + .unwrap(); + render_lines(events, source, links).unwrap() + } + + fn upstream_lines(lang: &str, code: &str) -> Vec { + let config = tree_sitter_language_cb(lang).unwrap(); + let source = code.as_bytes(); + let mut highlighter = tree_sitter_highlight::Highlighter::new(); + #[allow(clippy::redundant_closure)] + let events = highlighter + .highlight(config, source, None, |e| tree_sitter_language_cb(e)) + .unwrap(); + let mut renderer = tree_sitter_highlight::HtmlRenderer::new(); + renderer + .render(events, source, &|highlight| classes(highlight)) + .unwrap(); + renderer.lines().map(|line| line.to_string()).collect() + } + + /// Without links, the hand-rolled renderer must be a drop-in replacement for + /// the upstream one, which every markdown code fence still goes through. + #[test] + fn matches_upstream_renderer() { + for (lang, code) in [ + ( + "ts", + "import { a } from \"./a.ts\";\nexport const x: number = 1;\n", + ), + // no trailing newline + ("ts", "const x = 1;"), + // CRLF, blank lines, and a string spanning a line break + ("ts", "const a = 1;\r\n\r\nconst b = `multi\nline`;\r\n"), + // characters that need escaping, and non-ASCII ones that must not be + // mangled by the byte-wise escaping + ("ts", "const s = \"\";\nconst β = 'π';\n"), + ("md", "# Title\n\nSome *text*.\n"), + ("json", "{\n \"a\": [1, 2]\n}\n"), + ] { + assert_eq!( + rendered_lines(lang, code, &[]), + upstream_lines(lang, code), + "mismatch for {lang}: {code:?}" + ); + } + } + + #[test] + fn wraps_linked_ranges_in_anchors() { + let code = "import { a } from \"./a.ts\";\n"; + let start = code.find("\"./a.ts\"").unwrap(); + let links = vec![SourceLink { + range: start..start + "\"./a.ts\"".len(), + href: "/@scope/pkg/1.0.0/a.ts".to_string(), + }]; + + let lines = rendered_lines("ts", code, &links); + let html = lines.join(""); + + assert!( + html.contains( + r#""# + ), + "{html}" + ); + // the quotes are part of the link text, and are still escaped + assert!(html.contains(""./a.ts""), "{html}"); + // the rest of the line is untouched + assert_eq!(html.matches("").count(), 1, "{html}"); + } + + /// A link whose range covers several highlight spans becomes one anchor per + /// span, so the markup stays properly nested. + #[test] + fn anchors_never_cross_a_highlight_boundary() { + let code = "import { a } from \"./a.ts\";\n"; + let links = vec![SourceLink { + // deliberately covers the whole line, spanning keywords and strings + range: 0..code.len() - 1, + href: "/somewhere".to_string(), + }]; + + let html = rendered_lines("ts", code, &links).join(""); + assert_eq!( + html.matches("").count(), + "{html}" + ); + // every anchor closes before the span it sits in does + let mut depth = 0i32; + for token in html.split('<') { + if token.starts_with("a ") { + depth += 1; + } else if token.starts_with("/a>") { + depth -= 1; + } else if token.starts_with("/span>") { + assert_eq!(depth, 0, "anchor left open across a span: {html}"); + } + assert!(depth >= 0, "{html}"); + } + assert_eq!(depth, 0, "{html}"); + } + + /// A specifier on the last line of a file without a trailing newline still + /// gets its anchor, and no phantom line is emitted. + #[test] + fn links_on_a_final_line_without_a_newline() { + let code = "import \"./a.ts\";"; + let start = code.find("\"./a.ts\"").unwrap(); + let links = vec![SourceLink { + range: start..start + "\"./a.ts\"".len(), + href: "/a".to_string(), + }]; + + let lines = rendered_lines("ts", code, &links); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert!(lines[0].contains(r#"href="/a""#), "{lines:?}"); + assert!(lines[0].ends_with('\n'), "{lines:?}"); + } } diff --git a/crates/jsr_types/src/models.rs b/crates/jsr_types/src/models.rs index b2129115d..f6d88088f 100644 --- a/crates/jsr_types/src/models.rs +++ b/crates/jsr_types/src/models.rs @@ -518,6 +518,11 @@ pub struct PackageVersionMeta { pub percentage_documented_symbols: f32, pub all_fast_check: bool, // mean no slow types pub has_provenance: bool, + /// Number of distinct symbols the generated docs list for this version, used + /// to show the package's API surface area in the nav (jsr-io/jsr#1206). + /// `None` on versions published before this was recorded, which render + /// without the chip rather than claiming zero symbols. + pub symbol_count: Option, } #[cfg(feature = "sqlx")] @@ -1226,6 +1231,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 +1255,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/assets/styles.css b/frontend/assets/styles.css index 8312acbd2..671913d69 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -392,7 +392,7 @@ body .ddoc { @apply bg-jsr-cyan-100/50 dark:bg-jsr-cyan-900/50; } - a:not(.no_color) { + a:not(.no_color, .anchor) { @apply text-jsr-cyan-700 hover:text-jsr-cyan-900 underline decoration-jsr-cyan-700/50 underline-offset-2 outline-none focus-visible:ring-2 ring-jsr-cyan-700 ring-offset-2 rounded-sm @@ -419,6 +419,18 @@ body .ddoc { } } + /* Import/export specifiers in the file view. These keep the syntax + colour of the string they sit in (hence `no_color` on the anchor) and + only announce themselves as links on hover and focus. */ + .specifierLink { + @apply underline decoration-dotted underline-offset-2 + decoration-jsr-gray-400 rounded-sm outline-none + hover:decoration-solid hover:decoration-jsr-cyan-700 + focus-visible:ring-2 ring-jsr-cyan-700 + dark:decoration-jsr-gray-500 dark:hover:decoration-cyan-400 + dark:ring-cyan-400; + } + /*! * GitHub Light v0.5.0 * Copyright (c) 2012 - 2017 GitHub, Inc. diff --git a/frontend/components/doc/NamespaceSection.tsx b/frontend/components/doc/NamespaceSection.tsx index 052d68c67..03530f86f 100644 --- a/frontend/components/doc/NamespaceSection.tsx +++ b/frontend/components/doc/NamespaceSection.tsx @@ -10,6 +10,9 @@ export function NamespaceSection({ items }: { items: NamespaceNodeCtx[] }) { const renamedOldName = item.diff_status?.kind === "renamed" ? item.diff_status.old_name : undefined; + // a symbol documented only by `@deprecated ` falls back to + // that message rather than reading as undocumented + const docs = item.docs ?? item.deprecated_doc; return (
- {item.docs + {docs ? ( ) : ( 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 && ( +
+ + +
+ + + +
+
+ )} +