mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
feat(admin): add internal notes to reports (#3510)
This commit is contained in:
1 parent
2d8d130be1
commit
49a1ffabbe
30 files changed
+1138
-87
No files matched your search
Generated
+167
-2
@@ -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"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(¬e_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,
|
||||
¬e_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, ¬e.author_id))
|
||||
" on " (format_admin_timestamp(¬e.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(¬e_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, ¬e.author_id),
|
||||
body: ¬e.body,
|
||||
created_at: ¬e.created_at,
|
||||
})
|
||||
.collect();
|
||||
staff_notes_section(
|
||||
¬es,
|
||||
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: ¬e.note_id,
|
||||
author: user_link(base, note_authors, ¬e.author_id),
|
||||
body: ¬e.body,
|
||||
created_at: ¬e.created_at,
|
||||
})
|
||||
.collect();
|
||||
staff_notes_section(
|
||||
¬es,
|
||||
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))
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in new issue
Block a user