feat(admin): add internal notes to reports (#3510)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-17 05:50:33 +02:00
1 parent 2d8d130be1
commit 49a1ffabbe
30 files changed
+1138 -87

No files matched your search

+167 -2
View File
@@ -5655,7 +5655,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Retrieves detailed information about a specific report including content, reporter, reason, and the message context and profile snapshot captured when it was filed. Requires REPORT_VIEW permission.",
"description": "Retrieves detailed information about a specific report including content, reporter, reason, the message context and profile snapshot captured when it was filed, and its internal notes. Requires REPORT_VIEW permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -5782,7 +5782,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Deletes a report now: its record with the captured message context and profile snapshot, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.",
"description": "Deletes a report now: its record with the captured message context and profile snapshot, its notes, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -5867,6 +5867,138 @@
}
}
},
"/admin/reports/{report_id}/notes": {
"get": {
"operationId": "list_admin_report_notes",
"summary": "List report notes",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportNoteListResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Lists the internal notes on a report, oldest first. Notes are staff only and are deleted with the report. Requires REPORT_VIEW permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "report_id",
"in": "path",
"required": true,
"schema": {
"description": "The ID of the report",
"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]
},
"description": "The ID of the report"
}
]
},
"post": {
"operationId": "annotate_admin_report",
"summary": "Add a note to a report",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportNoteResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Appends an internal note to a report. Notes are staff only, never change and are deleted with the report. Creates audit log entry. Requires REPORT_RESOLVE permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "report_id",
"in": "path",
"required": true,
"schema": {
"description": "The ID of the report",
"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]
},
"description": "The ID of the report"
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportNoteRequest"}}}
}
}
},
"/admin/search/index-refreshes/{job_id}": {
"get": {
"operationId": "get_admin_search_index_refresh",
@@ -10969,6 +11101,34 @@
}
]
},
"ReportNoteRequest": {
"type": "object",
"properties": {"body": {"description": "The internal note. Staff only", "type": "string"}},
"required": ["body"]
},
"ReportNoteResponse": {
"type": "object",
"properties": {
"note_id": {"$ref": "#/components/schemas/SnowflakeStringType"},
"author_id": {"$ref": "#/components/schemas/SnowflakeStringType"},
"body": {"type": "string"},
"created_at": {"type": "string"}
},
"required": ["note_id", "author_id", "body", "created_at"],
"additionalProperties": false
},
"ReportNoteListResponse": {
"type": "object",
"properties": {
"notes": {
"type": "array",
"items": {"$ref": "#/components/schemas/ReportNoteResponse"},
"description": "The notes on the report, oldest first"
}
},
"required": ["notes"],
"additionalProperties": false
},
"ReportLegalHoldRequest": {
"type": "object",
"properties": {
@@ -11406,6 +11566,11 @@
"nullable": true,
"type": "string"
},
"notes": {
"description": "Internal staff notes on the report, oldest first. Returned by the report detail only.",
"type": "array",
"items": {"$ref": "#/components/schemas/ReportNoteResponse"}
},
"reason": {"nullable": true, "type": "string"},
"reason_label": {"nullable": true, "type": "string"},
"reason_highest_priority": {"nullable": true, "type": "boolean"},
+16 -2
View File
@@ -5,8 +5,8 @@ use reqwest::Method;
use crate::api::generated::{
snowflake,
types::{
UpdateReportRequest, UpdateReportRequestOutcome, UpdateReportRequestResolution,
UpdateReportRequestStatus,
ReportNoteRequest, ReportNoteResponse, UpdateReportRequest, UpdateReportRequestOutcome,
UpdateReportRequestResolution, UpdateReportRequestStatus,
},
};
@@ -94,6 +94,20 @@ impl AdminApiClient {
.await
}
pub async fn annotate_report(
&self,
report_id: &str,
body: String,
) -> ApiResult<ReportNoteResponse> {
self.send(
Method::POST,
&format!("/admin/reports/{}/notes", urlencoding::encode(report_id)),
Some(&ReportNoteRequest { body }),
None,
)
.await
}
pub async fn delete_report(
&self,
report_id: &str,
+10
View File
@@ -171,6 +171,16 @@ pub struct ReportEntry {
pub legal_hold_until: Option<String>,
#[serde(default)]
pub legal_hold_reason: Option<String>,
#[serde(default)]
pub notes: Vec<ReportNoteEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReportNoteEntry {
pub note_id: String,
pub author_id: String,
pub body: String,
pub created_at: String,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+70 -1
View File
@@ -62,6 +62,14 @@ struct LegalHoldForm {
clear: Option<String>,
}
#[derive(Deserialize)]
struct NoteForm {
#[serde(default)]
_csrf: Option<String>,
#[serde(default)]
body: Option<String>,
}
#[derive(Deserialize)]
struct DeleteForm {
#[serde(default)]
@@ -99,6 +107,10 @@ pub fn router() -> Router<AppState> {
"/reports/{report_id}/resolve",
axum::routing::post(report_resolve),
)
.route(
"/reports/{report_id}/notes",
axum::routing::post(report_note),
)
.route(
"/reports/{report_id}/legal-hold",
axum::routing::post(report_legal_hold),
@@ -215,12 +227,21 @@ async fn report_detail(
.log_error("load report detail");
match report {
Some(report) => {
let live = load_live_profile(&client, &auth.0, &report).await;
let note_author_ids = note_author_ids(&report);
let (live, note_authors) =
tokio::join!(load_live_profile(&client, &auth.0, &report), async {
client
.lookup_users_by_ids(&note_author_ids)
.await
.log_error("load report note authors")
.unwrap_or_default()
});
let markup = templates::pages::report_detail::report_detail_page(
config,
&auth.0,
&report,
&live,
&note_authors,
&csrf_token,
is_detail_fragment,
);
@@ -233,6 +254,17 @@ async fn report_detail(
}
}
fn note_author_ids(report: &ReportEntry) -> Vec<String> {
let mut ids: Vec<String> = report
.notes
.iter()
.map(|note| note.author_id.clone())
.collect();
ids.sort_unstable();
ids.dedup();
ids
}
async fn load_live_profile(
client: &AdminApiClient,
auth: &AuthContext,
@@ -381,6 +413,43 @@ async fn report_resolve(
flash::redirect_with_flash(&back, flash, config.secure_cookies())
}
async fn report_note(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
Path(report_id): Path<String>,
request: Request,
) -> Response {
let config = state.config();
let back = format!("{}/reports/{report_id}", config.base_path);
let form: NoteForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
tracing::warn!(%error, report_id, "failed to parse report note form");
return flash::redirect_with_flash(
&back,
FlashData::error("Invalid form data"),
config.secure_cookies(),
);
}
};
let Some(body) = clean_string(form.body.as_deref().unwrap_or("")) else {
return flash::redirect_with_flash(
&back,
FlashData::error("Write a note first"),
config.secure_cookies(),
);
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let flash = match client.annotate_report(&report_id, body).await {
Ok(_) => FlashData::success("Note added"),
Err(error) => {
tracing::warn!(%error, report_id, "admin API request failed: add report note");
FlashData::error("Failed to add the note")
}
};
flash::redirect_with_flash(&back, flash, config.secure_cookies())
}
const RESOLUTION_REQUIRED: &str = "Choose a resolution";
const OUTCOME_REQUIRED: &str = "Choose what happened to the reported content or account";
const DUPLICATE_OF_REQUIRED: &str = "Enter the ID of the report this one repeats";
@@ -22,6 +22,7 @@ pub mod report_category;
pub mod report_webhook;
pub mod resource_link;
pub mod section_card;
pub mod staff_notes;
pub mod table;
pub mod tooltip;
pub mod user_display;
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
templates::components::{
enforcement_fields::{FIELD_HELP_CLASS, FIELD_INPUT_CLASS, field_label},
form::{csrf_input, form_actions, submit_button},
section_card::section_card_simple,
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
const NOTE_MAX_CHARS: usize = 2000;
pub struct StaffNote<'a> {
pub note_id: &'a str,
pub author: Markup,
pub body: &'a str,
pub created_at: &'a str,
}
pub struct StaffNoteForm<'a> {
pub action: String,
pub refresh_target: &'a str,
pub field_id: String,
pub help: &'a str,
pub csrf_token: &'a str,
}
pub fn staff_notes_section(notes: &[StaffNote], form: Option<StaffNoteForm>) -> Markup {
html! {
(section_card_simple("Notes", html! {
div class="space-y-4" {
@if notes.is_empty() {
p class="text-sm text-neutral-500" { "No notes." }
} @else {
ol class="space-y-3" {
@for note in notes {
li class="space-y-1" data-note=(note.note_id) {
p class="text-xs text-neutral-500" {
(note.author)
" on " (format_admin_timestamp(note.created_at))
}
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (note.body) }
}
}
}
}
@if let Some(form) = form {
form method="post" action=(form.action)
data-admin-refresh-on-success=(form.refresh_target) {
(csrf_input(form.csrf_token))
div class="space-y-3" {
div class="space-y-1" {
(field_label(&form.field_id, "Add a note"))
textarea id=(form.field_id) name="body" rows="2" required maxlength=(NOTE_MAX_CHARS)
class=(FIELD_INPUT_CLASS) {}
p class=(FIELD_HELP_CLASS) { (form.help) }
}
(form_actions(submit_button("Add note")))
}
}
}
}
}))
}
}
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::user_tag::user_tag;
use crate::{api::types::AdminUser, utils::user_tag::user_tag};
use maud::{Markup, html};
pub const SYSTEM_USER_ID: &str = "0";
pub fn format_user_display(
global_name: Option<&str>,
@@ -16,3 +19,29 @@ pub fn format_user_display(
_ => "Unknown".to_owned(),
}
}
pub fn user_name(users: &[AdminUser], id: &str) -> String {
if id == SYSTEM_USER_ID {
return "System".to_owned();
}
users.iter().find(|user| user.id == id).map_or_else(
|| id.to_owned(),
|user| {
format_user_display(
user.global_name.as_deref(),
Some(&user.username),
Some(&user.discriminator),
user.bot,
)
},
)
}
pub fn user_link(base: &str, users: &[AdminUser], id: &str) -> Markup {
if id == SYSTEM_USER_ID {
return html! { "System" };
}
html! {
a href={(base) "/users/" (id)} class="text-blue-600 hover:underline" { (user_name(users, id)) }
}
}
@@ -29,7 +29,8 @@ pub fn action_badge_variant(action: &str) -> BadgeVariant {
| "set_acls"
| "update_settings"
| "annotate_ban"
| "annotate_enforcement_decision" => BadgeVariant::Info,
| "annotate_enforcement_decision"
| "annotate_report" => BadgeVariant::Info,
"delete_message" | "warn_user" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
}
@@ -23,7 +23,8 @@ use crate::{
form::{csrf_input, form_actions, submit_button},
page_container::{page_header, page_header_with_back},
section_card::section_card_simple,
user_display::format_user_display,
staff_notes::{StaffNote, StaffNoteForm, staff_notes_section},
user_display::{SYSTEM_USER_ID, user_link},
},
layout::admin_layout,
},
@@ -31,8 +32,6 @@ use crate::{
};
use maud::{Markup, html};
const SYSTEM_USER_ID: &str = "0";
const NOTE_MAX_CHARS: usize = 2000;
const CORRECTION_LINE: &str = "If this decision was reported to an authority, file a correction.";
const DECISIONS_PAGE_KEY: &str = "decisions";
const PERMANENT_SUSPENSION_THRESHOLD_YEARS: i32 = 50;
@@ -223,32 +222,6 @@ pub fn end_decision_form(form: &EndDecisionForm<'_>) -> Markup {
}
}
fn user_name(users: &[AdminUser], id: &str) -> String {
if id == SYSTEM_USER_ID {
return "System".to_owned();
}
users.iter().find(|user| user.id == id).map_or_else(
|| id.to_owned(),
|user| {
format_user_display(
user.global_name.as_deref(),
Some(&user.username),
Some(&user.discriminator),
user.bot,
)
},
)
}
fn user_link(base: &str, users: &[AdminUser], id: &str) -> Markup {
if id == SYSTEM_USER_ID {
return html! { "System" };
}
html! {
a href={(base) "/users/" (id)} class="text-blue-600 hover:underline" { (user_name(users, id)) }
}
}
fn field(label: &str, value: Markup) -> Markup {
html! {
div class="flex min-w-0 flex-col gap-1" {
@@ -527,44 +500,29 @@ fn notes_section(
can_annotate: bool,
csrf_token: &str,
) -> Markup {
let note_id = format!("note-{}", decision.decision_id.as_str());
html! {
(section_card_simple("Notes", html! {
div class="space-y-4" {
@if decision.notes.is_empty() {
p class="text-sm text-neutral-500" { "No notes." }
} @else {
ol class="space-y-3" {
@for note in &decision.notes {
li class="space-y-1" data-note=(note.note_id.as_str()) {
p class="text-xs text-neutral-500" {
(user_link(base, users, &note.author_id))
" on " (format_admin_timestamp(&note.created_at))
}
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (note.body) }
}
}
}
}
@if can_annotate {
form method="post"
action={(base) "/decisions/" (decision.decision_id.as_str()) "?action=add_note"}
data-admin-refresh-on-success="#decision-detail" {
(csrf_input(csrf_token))
div class="space-y-3" {
div class="space-y-1" {
(field_label(&note_id, "Add a note"))
textarea id=(note_id) name="body" rows="2" required maxlength=(NOTE_MAX_CHARS)
class=(FIELD_INPUT_CLASS) {}
p class=(FIELD_HELP_CLASS) { "Staff only. Notes cannot be changed and expire after a year." }
}
(form_actions(submit_button("Add note")))
}
}
}
}
}))
}
let notes: Vec<StaffNote> = decision
.notes
.iter()
.map(|note| StaffNote {
note_id: note.note_id.as_str(),
author: user_link(base, users, &note.author_id),
body: &note.body,
created_at: &note.created_at,
})
.collect();
staff_notes_section(
&notes,
can_annotate.then(|| StaffNoteForm {
action: format!(
"{base}/decisions/{}?action=add_note",
decision.decision_id.as_str()
),
refresh_target: "#decision-detail",
field_id: format!("note-{}", decision.decision_id.as_str()),
help: "Staff only. Notes cannot be changed and expire after a year.",
csrf_token,
}),
)
}
pub fn decision_user_ids(decision: &EnforcementDecisionResponse) -> Vec<String> {
@@ -41,6 +41,8 @@ use crate::{
},
resource_link::{ResourceType, resource_link},
section_card::section_card,
staff_notes::{StaffNote, StaffNoteForm, staff_notes_section},
user_display::user_link,
},
layout::admin_layout,
},
@@ -944,6 +946,36 @@ fn snapshot_guild_group(
}
}
fn notes_card(
config: &AdminConfig,
auth: &AuthContext,
report: &ReportEntry,
note_authors: &[AdminUser],
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
let notes: Vec<StaffNote> = report
.notes
.iter()
.map(|note| StaffNote {
note_id: &note.note_id,
author: user_link(base, note_authors, &note.author_id),
body: &note.body,
created_at: &note.created_at,
})
.collect();
staff_notes_section(
&notes,
has_acl(auth, acl::REPORT_RESOLVE).then(|| StaffNoteForm {
action: format!("{base}/reports/{}/notes", report.report_id),
refresh_target: "#report-detail",
field_id: format!("note-{}", report.report_id),
help: "Staff only. Never shown to the reporter or the reported account. Notes cannot be changed and are deleted with the report.",
csrf_token,
}),
)
}
fn legal_hold_card(
config: &AdminConfig,
auth: &AuthContext,
@@ -1041,7 +1073,7 @@ fn delete_report_card(
(section_card(Some("Delete Report"), None, None, html! {
div class="flex flex-col gap-3" data-report-delete=(if held { "held" } else { "available" }) {
p class="text-neutral-500 text-sm" {
"Deletes the report now, with its message context, profile snapshot, stored evidence and search entry. \
"Deletes the report now, with its message context, profile snapshot, notes, stored evidence and search entry. \
Evidence that another report uses is kept. This cannot be undone."
}
@if held {
@@ -1129,6 +1161,7 @@ pub fn report_detail_page(
auth: &AuthContext,
report: &ReportEntry,
live: &LiveProfile,
note_authors: &[AdminUser],
csrf_token: &str,
is_htmx: bool,
) -> Markup {
@@ -1149,6 +1182,7 @@ pub fn report_detail_page(
(profile_snapshot_section(config, report, live))
(message_context_section(config, report, can_take_action(admin_acls(auth))))
(additional_info_section(report))
(notes_card(config, auth, report, note_authors, csrf_token))
}
div class="space-y-6" {
(status_card(config, report))
+133
View File
@@ -140,6 +140,128 @@ async fn report_legal_hold_form_places_and_clears_the_hold() {
assert_eq!(received.lock().unwrap().len(), 3);
}
#[tokio::test]
async fn report_notes_show_the_author_and_time_and_the_form_adds_one() {
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
let sink = received.clone();
let api = Router::new()
.route(
"/admin/reports/1800000000000000001",
routing::get(|| async { json_response(report_with_note()) }),
)
.route(
"/admin/reports/1800000000000000001/notes",
routing::post(move |Json(body): Json<Value>| {
let sink = sink.clone();
async move {
sink.lock().unwrap().push(body.clone());
json_response(json!({
"note_id": "1950000000000000002",
"author_id": "1500000000000000000",
"body": body["body"],
"created_at": "2026-05-27T08:00:00.000Z"
}))
}
}),
)
.route(
"/admin/users",
routing::get(|| async {
json_response(json!({ "users": [user("1500000000000000000", "AdminUser")] }))
}),
)
.fallback(mock_api);
let app = setup_with_api(api).await;
let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
let note_start = page
.find(r#"data-note="1950000000000000001""#)
.unwrap_or_else(|| panic!("missing note\n{page}"));
let note = &page[note_start..page[note_start..].find("</li>").unwrap() + note_start];
assert!(
note.contains(r#"href="/users/1500000000000000000""#),
"{note}"
);
assert!(note.contains("AdminUser"), "{note}");
assert!(note.contains("May 26, 2026, 1:30 PM UTC"), "{note}");
assert!(
note.contains("Reporter sent more context by email"),
"{note}"
);
let csrf_token = csrf_cookie(&headers)
.unwrap_or_else(|| panic!("report page did not set csrf_token cookie\n{page}"));
assert_form_has_csrf(&page, "/reports/1800000000000000001/notes", &csrf_token);
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
let post = |body: String| {
let app = &app;
let cookie = cookie.clone();
async move {
let (status, headers, text) = post_form_with_headers(
app,
"/reports/1800000000000000001/notes",
&[
("HX-Request", "true"),
("HX-Target", "flash-container"),
("Cookie", &cookie),
],
&body,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{text}");
headers
.get("X-Fluxer-Admin-Toast")
.and_then(|value| value.to_str().ok())
.unwrap_or_else(|| panic!("missing toast header\n{text}"))
.to_owned()
}
};
let toast = post(format!(
"_csrf={csrf_token}&body=++Waiting+on+the+guild+owner++"
))
.await;
assert!(toast.contains("success"), "{toast}");
assert!(toast.contains("Note added"), "{toast}");
let toast = post(format!("_csrf={csrf_token}&body=+")).await;
assert!(toast.contains("error"), "{toast}");
assert!(toast.contains("Write a note first"), "{toast}");
assert_eq!(
*received.lock().unwrap(),
vec![json!({"body": "Waiting on the guild owner"})]
);
}
#[tokio::test]
async fn report_notes_hide_the_form_without_report_resolve() {
let api = Router::new()
.route(
"/admin/users/@me",
routing::get(|| async {
let mut admin = user("1500000000000000000", "AdminUser");
admin["acls"] = json!(["report:view"]);
json_response(json!({ "user": admin }))
}),
)
.route(
"/admin/reports/1800000000000000001",
routing::get(|| async { json_response(report_with_note()) }),
)
.fallback(mock_api);
let app = setup_with_api(api).await;
let (_, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
assert!(
page.contains("Reporter sent more context by email"),
"{page}"
);
assert!(
page.contains(r#"href="/users/1500000000000000000""#),
"{page}"
);
assert!(
!page.contains(r#"action="/reports/1800000000000000001/notes""#),
"{page}"
);
}
#[tokio::test]
async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report() {
let received = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
@@ -746,6 +868,17 @@ fn searched_report() -> Value {
})
}
fn report_with_note() -> Value {
let mut report = searched_report();
report["notes"] = json!([{
"note_id": "1950000000000000001",
"author_id": "1500000000000000000",
"body": "Reporter sent more context by email",
"created_at": "2026-05-26T13:30:00.000Z"
}]);
report
}
fn report_reasons() -> Value {
json!({
"reasons": [