mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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"}}}
|
"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": []}],
|
"security": [{"adminApiKey": []}],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -5782,7 +5782,7 @@
|
|||||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
"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": []}],
|
"security": [{"adminApiKey": []}],
|
||||||
"parameters": [
|
"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}": {
|
"/admin/search/index-refreshes/{job_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "get_admin_search_index_refresh",
|
"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": {
|
"ReportLegalHoldRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -11406,6 +11566,11 @@
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string"
|
"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": {"nullable": true, "type": "string"},
|
||||||
"reason_label": {"nullable": true, "type": "string"},
|
"reason_label": {"nullable": true, "type": "string"},
|
||||||
"reason_highest_priority": {"nullable": true, "type": "boolean"},
|
"reason_highest_priority": {"nullable": true, "type": "boolean"},
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use reqwest::Method;
|
|||||||
use crate::api::generated::{
|
use crate::api::generated::{
|
||||||
snowflake,
|
snowflake,
|
||||||
types::{
|
types::{
|
||||||
UpdateReportRequest, UpdateReportRequestOutcome, UpdateReportRequestResolution,
|
ReportNoteRequest, ReportNoteResponse, UpdateReportRequest, UpdateReportRequestOutcome,
|
||||||
UpdateReportRequestStatus,
|
UpdateReportRequestResolution, UpdateReportRequestStatus,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,6 +94,20 @@ impl AdminApiClient {
|
|||||||
.await
|
.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(
|
pub async fn delete_report(
|
||||||
&self,
|
&self,
|
||||||
report_id: &str,
|
report_id: &str,
|
||||||
|
|||||||
@@ -171,6 +171,16 @@ pub struct ReportEntry {
|
|||||||
pub legal_hold_until: Option<String>,
|
pub legal_hold_until: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub legal_hold_reason: Option<String>,
|
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)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ struct LegalHoldForm {
|
|||||||
clear: Option<String>,
|
clear: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NoteForm {
|
||||||
|
#[serde(default)]
|
||||||
|
_csrf: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DeleteForm {
|
struct DeleteForm {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -99,6 +107,10 @@ pub fn router() -> Router<AppState> {
|
|||||||
"/reports/{report_id}/resolve",
|
"/reports/{report_id}/resolve",
|
||||||
axum::routing::post(report_resolve),
|
axum::routing::post(report_resolve),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/reports/{report_id}/notes",
|
||||||
|
axum::routing::post(report_note),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/reports/{report_id}/legal-hold",
|
"/reports/{report_id}/legal-hold",
|
||||||
axum::routing::post(report_legal_hold),
|
axum::routing::post(report_legal_hold),
|
||||||
@@ -215,12 +227,21 @@ async fn report_detail(
|
|||||||
.log_error("load report detail");
|
.log_error("load report detail");
|
||||||
match report {
|
match report {
|
||||||
Some(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(
|
let markup = templates::pages::report_detail::report_detail_page(
|
||||||
config,
|
config,
|
||||||
&auth.0,
|
&auth.0,
|
||||||
&report,
|
&report,
|
||||||
&live,
|
&live,
|
||||||
|
¬e_authors,
|
||||||
&csrf_token,
|
&csrf_token,
|
||||||
is_detail_fragment,
|
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(
|
async fn load_live_profile(
|
||||||
client: &AdminApiClient,
|
client: &AdminApiClient,
|
||||||
auth: &AuthContext,
|
auth: &AuthContext,
|
||||||
@@ -381,6 +413,43 @@ async fn report_resolve(
|
|||||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
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 RESOLUTION_REQUIRED: &str = "Choose a resolution";
|
||||||
const OUTCOME_REQUIRED: &str = "Choose what happened to the reported content or account";
|
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";
|
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 report_webhook;
|
||||||
pub mod resource_link;
|
pub mod resource_link;
|
||||||
pub mod section_card;
|
pub mod section_card;
|
||||||
|
pub mod staff_notes;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod tooltip;
|
pub mod tooltip;
|
||||||
pub mod user_display;
|
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
|
// 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(
|
pub fn format_user_display(
|
||||||
global_name: Option<&str>,
|
global_name: Option<&str>,
|
||||||
@@ -16,3 +19,29 @@ pub fn format_user_display(
|
|||||||
_ => "Unknown".to_owned(),
|
_ => "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"
|
| "set_acls"
|
||||||
| "update_settings"
|
| "update_settings"
|
||||||
| "annotate_ban"
|
| "annotate_ban"
|
||||||
| "annotate_enforcement_decision" => BadgeVariant::Info,
|
| "annotate_enforcement_decision"
|
||||||
|
| "annotate_report" => BadgeVariant::Info,
|
||||||
"delete_message" | "warn_user" => BadgeVariant::Warning,
|
"delete_message" | "warn_user" => BadgeVariant::Warning,
|
||||||
_ => BadgeVariant::Default,
|
_ => BadgeVariant::Default,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ use crate::{
|
|||||||
form::{csrf_input, form_actions, submit_button},
|
form::{csrf_input, form_actions, submit_button},
|
||||||
page_container::{page_header, page_header_with_back},
|
page_container::{page_header, page_header_with_back},
|
||||||
section_card::section_card_simple,
|
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,
|
layout::admin_layout,
|
||||||
},
|
},
|
||||||
@@ -31,8 +32,6 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use maud::{Markup, html};
|
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 CORRECTION_LINE: &str = "If this decision was reported to an authority, file a correction.";
|
||||||
const DECISIONS_PAGE_KEY: &str = "decisions";
|
const DECISIONS_PAGE_KEY: &str = "decisions";
|
||||||
const PERMANENT_SUSPENSION_THRESHOLD_YEARS: i32 = 50;
|
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 {
|
fn field(label: &str, value: Markup) -> Markup {
|
||||||
html! {
|
html! {
|
||||||
div class="flex min-w-0 flex-col gap-1" {
|
div class="flex min-w-0 flex-col gap-1" {
|
||||||
@@ -527,44 +500,29 @@ fn notes_section(
|
|||||||
can_annotate: bool,
|
can_annotate: bool,
|
||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
let note_id = format!("note-{}", decision.decision_id.as_str());
|
let notes: Vec<StaffNote> = decision
|
||||||
html! {
|
.notes
|
||||||
(section_card_simple("Notes", html! {
|
.iter()
|
||||||
div class="space-y-4" {
|
.map(|note| StaffNote {
|
||||||
@if decision.notes.is_empty() {
|
note_id: note.note_id.as_str(),
|
||||||
p class="text-sm text-neutral-500" { "No notes." }
|
author: user_link(base, users, ¬e.author_id),
|
||||||
} @else {
|
body: ¬e.body,
|
||||||
ol class="space-y-3" {
|
created_at: ¬e.created_at,
|
||||||
@for note in &decision.notes {
|
})
|
||||||
li class="space-y-1" data-note=(note.note_id.as_str()) {
|
.collect();
|
||||||
p class="text-xs text-neutral-500" {
|
staff_notes_section(
|
||||||
(user_link(base, users, ¬e.author_id))
|
¬es,
|
||||||
" on " (format_admin_timestamp(¬e.created_at))
|
can_annotate.then(|| StaffNoteForm {
|
||||||
}
|
action: format!(
|
||||||
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (note.body) }
|
"{base}/decisions/{}?action=add_note",
|
||||||
}
|
decision.decision_id.as_str()
|
||||||
}
|
),
|
||||||
}
|
refresh_target: "#decision-detail",
|
||||||
}
|
field_id: format!("note-{}", decision.decision_id.as_str()),
|
||||||
@if can_annotate {
|
help: "Staff only. Notes cannot be changed and expire after a year.",
|
||||||
form method="post"
|
csrf_token,
|
||||||
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")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decision_user_ids(decision: &EnforcementDecisionResponse) -> Vec<String> {
|
pub fn decision_user_ids(decision: &EnforcementDecisionResponse) -> Vec<String> {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
resource_link::{ResourceType, resource_link},
|
resource_link::{ResourceType, resource_link},
|
||||||
section_card::section_card,
|
section_card::section_card,
|
||||||
|
staff_notes::{StaffNote, StaffNoteForm, staff_notes_section},
|
||||||
|
user_display::user_link,
|
||||||
},
|
},
|
||||||
layout::admin_layout,
|
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(
|
fn legal_hold_card(
|
||||||
config: &AdminConfig,
|
config: &AdminConfig,
|
||||||
auth: &AuthContext,
|
auth: &AuthContext,
|
||||||
@@ -1041,7 +1073,7 @@ fn delete_report_card(
|
|||||||
(section_card(Some("Delete Report"), None, None, html! {
|
(section_card(Some("Delete Report"), None, None, html! {
|
||||||
div class="flex flex-col gap-3" data-report-delete=(if held { "held" } else { "available" }) {
|
div class="flex flex-col gap-3" data-report-delete=(if held { "held" } else { "available" }) {
|
||||||
p class="text-neutral-500 text-sm" {
|
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."
|
Evidence that another report uses is kept. This cannot be undone."
|
||||||
}
|
}
|
||||||
@if held {
|
@if held {
|
||||||
@@ -1129,6 +1161,7 @@ pub fn report_detail_page(
|
|||||||
auth: &AuthContext,
|
auth: &AuthContext,
|
||||||
report: &ReportEntry,
|
report: &ReportEntry,
|
||||||
live: &LiveProfile,
|
live: &LiveProfile,
|
||||||
|
note_authors: &[AdminUser],
|
||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
is_htmx: bool,
|
is_htmx: bool,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
@@ -1149,6 +1182,7 @@ pub fn report_detail_page(
|
|||||||
(profile_snapshot_section(config, report, live))
|
(profile_snapshot_section(config, report, live))
|
||||||
(message_context_section(config, report, can_take_action(admin_acls(auth))))
|
(message_context_section(config, report, can_take_action(admin_acls(auth))))
|
||||||
(additional_info_section(report))
|
(additional_info_section(report))
|
||||||
|
(notes_card(config, auth, report, note_authors, csrf_token))
|
||||||
}
|
}
|
||||||
div class="space-y-6" {
|
div class="space-y-6" {
|
||||||
(status_card(config, report))
|
(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);
|
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]
|
#[tokio::test]
|
||||||
async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report() {
|
async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report() {
|
||||||
let received = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
|
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 {
|
fn report_reasons() -> Value {
|
||||||
json!({
|
json!({
|
||||||
"reasons": [
|
"reasons": [
|
||||||
|
|||||||
@@ -245,7 +245,9 @@ import {
|
|||||||
type DSAReportTicketRow,
|
type DSAReportTicketRow,
|
||||||
GUILD_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
|
GUILD_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
|
||||||
type GuildReportSubmissionByReporterRow,
|
type GuildReportSubmissionByReporterRow,
|
||||||
|
IAR_REPORT_NOTE_COLUMNS,
|
||||||
IAR_SUBMISSION_COLUMNS,
|
IAR_SUBMISSION_COLUMNS,
|
||||||
|
type IARReportNoteRow,
|
||||||
type IARSubmissionRow,
|
type IARSubmissionRow,
|
||||||
MESSAGE_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
|
MESSAGE_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
|
||||||
type MessageReportSubmissionByReporterRow,
|
type MessageReportSubmissionByReporterRow,
|
||||||
@@ -941,6 +943,12 @@ export const IARSubmissions = defineTable<IARSubmissionRow, 'report_id'>({
|
|||||||
columns: IAR_SUBMISSION_COLUMNS,
|
columns: IAR_SUBMISSION_COLUMNS,
|
||||||
primaryKey: ['report_id'],
|
primaryKey: ['report_id'],
|
||||||
});
|
});
|
||||||
|
export const IARReportNotes = defineTable<IARReportNoteRow, 'report_id' | 'note_id', 'report_id'>({
|
||||||
|
name: 'iar_report_notes',
|
||||||
|
columns: IAR_REPORT_NOTE_COLUMNS,
|
||||||
|
primaryKey: ['report_id', 'note_id'],
|
||||||
|
partitionKey: ['report_id'],
|
||||||
|
});
|
||||||
export const MessageReportSubmissionsByReporter = defineTable<
|
export const MessageReportSubmissionsByReporter = defineTable<
|
||||||
MessageReportSubmissionByReporterRow,
|
MessageReportSubmissionByReporterRow,
|
||||||
'reporter_id' | 'channel_id' | 'message_id',
|
'reporter_id' | 'channel_id' | 'message_id',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const AdminAuditReadActions = {
|
|||||||
LIST_GUILD_THREADS: 'list_guild_threads',
|
LIST_GUILD_THREADS: 'list_guild_threads',
|
||||||
LIST_NETWORK_BLOCK_CANDIDATES: 'list_network_block_candidates',
|
LIST_NETWORK_BLOCK_CANDIDATES: 'list_network_block_candidates',
|
||||||
LIST_PENDING_REGISTRATIONS: 'list_pending_registrations',
|
LIST_PENDING_REGISTRATIONS: 'list_pending_registrations',
|
||||||
|
LIST_REPORT_NOTES: 'list_report_notes',
|
||||||
LIST_REPORT_REASONS: 'list_report_reasons',
|
LIST_REPORT_REASONS: 'list_report_reasons',
|
||||||
LIST_USER_APPLICATIONS: 'list_user_applications',
|
LIST_USER_APPLICATIONS: 'list_user_applications',
|
||||||
LIST_USER_CHANGE_LOG: 'list_user_change_log',
|
LIST_USER_CHANGE_LOG: 'list_user_change_log',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
getEnforcementDecisionRepository,
|
getEnforcementDecisionRepository,
|
||||||
getGuildDiscoveryRepository,
|
getGuildDiscoveryRepository,
|
||||||
getKVAccountDeletionQueue,
|
getKVAccountDeletionQueue,
|
||||||
|
getReportRepository,
|
||||||
} from '@app/api/middleware/ServiceSingletons';
|
} from '@app/api/middleware/ServiceSingletons';
|
||||||
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
|
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
|
||||||
import type {ReportService} from '@app/api/report/ReportService';
|
import type {ReportService} from '@app/api/report/ReportService';
|
||||||
@@ -120,6 +121,7 @@ export class AdminService {
|
|||||||
this.reportServiceAggregate = new AdminReportService({
|
this.reportServiceAggregate = new AdminReportService({
|
||||||
apiContext: this.apiContext,
|
apiContext: this.apiContext,
|
||||||
reportService: this.reportService,
|
reportService: this.reportService,
|
||||||
|
reportRepository: getReportRepository(),
|
||||||
guildRepository: this.guildRepository,
|
guildRepository: this.guildRepository,
|
||||||
channelRepository: this.channelRepository,
|
channelRepository: this.channelRepository,
|
||||||
storageService: this.storageService,
|
storageService: this.storageService,
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
ReportLegalHoldRequest,
|
ReportLegalHoldRequest,
|
||||||
ReportLegalHoldResponse,
|
ReportLegalHoldResponse,
|
||||||
} from '@fluxer/schema/src/domains/admin/AdminReportLegalHoldSchemas';
|
} from '@fluxer/schema/src/domains/admin/AdminReportLegalHoldSchemas';
|
||||||
|
import {
|
||||||
|
ReportNoteListResponse,
|
||||||
|
ReportNoteRequest,
|
||||||
|
ReportNoteResponse,
|
||||||
|
} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
|
||||||
import {
|
import {
|
||||||
AdminReportListResponse,
|
AdminReportListResponse,
|
||||||
AdminReportReasonsResponse,
|
AdminReportReasonsResponse,
|
||||||
@@ -176,7 +181,7 @@ export function ReportAdminController(app: HonoApp) {
|
|||||||
operationId: 'get_admin_report',
|
operationId: 'get_admin_report',
|
||||||
summary: 'Get report',
|
summary: 'Get report',
|
||||||
description:
|
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.',
|
'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.',
|
||||||
responseSchema: ReportAdminResponseSchema,
|
responseSchema: ReportAdminResponseSchema,
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
security: 'adminApiKey',
|
security: 'adminApiKey',
|
||||||
@@ -234,6 +239,64 @@ export function ReportAdminController(app: HonoApp) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
app.get(
|
||||||
|
'/admin/reports/:report_id/notes',
|
||||||
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||||
|
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||||
|
Validator('param', ReportIdParam),
|
||||||
|
OpenAPI({
|
||||||
|
operationId: 'list_admin_report_notes',
|
||||||
|
summary: 'List report notes',
|
||||||
|
description:
|
||||||
|
'Lists the internal notes on a report, oldest first. Notes are staff only and are deleted with the report. Requires REPORT_VIEW permission.',
|
||||||
|
responseSchema: ReportNoteListResponse,
|
||||||
|
statusCode: 200,
|
||||||
|
security: 'adminApiKey',
|
||||||
|
tags: 'Admin',
|
||||||
|
}),
|
||||||
|
async (ctx) => {
|
||||||
|
const {report_id} = ctx.req.valid('param');
|
||||||
|
const result = await ctx.get('adminService').reportServiceAggregate.listNotes(createReportID(report_id));
|
||||||
|
await recordAdminRead(ctx, {
|
||||||
|
targetType: 'report',
|
||||||
|
targetId: report_id,
|
||||||
|
action: AdminAuditReadActions.LIST_REPORT_NOTES,
|
||||||
|
metadata: {result_count: result.notes.length},
|
||||||
|
});
|
||||||
|
return ctx.json(result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.post(
|
||||||
|
'/admin/reports/:report_id/notes',
|
||||||
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||||
|
requireAdminACL(AdminACLs.REPORT_RESOLVE),
|
||||||
|
Validator('param', ReportIdParam),
|
||||||
|
Validator('json', ReportNoteRequest),
|
||||||
|
OpenAPI({
|
||||||
|
operationId: 'annotate_admin_report',
|
||||||
|
summary: 'Add a note to a report',
|
||||||
|
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.',
|
||||||
|
responseSchema: ReportNoteResponse,
|
||||||
|
statusCode: 200,
|
||||||
|
security: 'adminApiKey',
|
||||||
|
tags: 'Admin',
|
||||||
|
}),
|
||||||
|
async (ctx) => {
|
||||||
|
const {report_id} = ctx.req.valid('param');
|
||||||
|
const reportId = createReportID(report_id);
|
||||||
|
const note = await ctx
|
||||||
|
.get('adminService')
|
||||||
|
.reportServiceAggregate.addNote(reportId, ctx.req.valid('json').body, ctx.get('adminUserId'));
|
||||||
|
await recordAdminWrite(ctx, {
|
||||||
|
targetType: 'report',
|
||||||
|
targetId: report_id,
|
||||||
|
action: 'annotate_report',
|
||||||
|
metadata: {report_id: reportId.toString(), note_id: note.note_id},
|
||||||
|
});
|
||||||
|
return ctx.json(note);
|
||||||
|
},
|
||||||
|
);
|
||||||
app.post(
|
app.post(
|
||||||
'/admin/reports/:report_id/legal-hold',
|
'/admin/reports/:report_id/legal-hold',
|
||||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||||
@@ -294,7 +357,7 @@ export function ReportAdminController(app: HonoApp) {
|
|||||||
operationId: 'delete_admin_report',
|
operationId: 'delete_admin_report',
|
||||||
summary: 'Delete report',
|
summary: 'Delete report',
|
||||||
description:
|
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.',
|
'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.',
|
||||||
responseSchema: null,
|
responseSchema: null,
|
||||||
statusCode: 204,
|
statusCode: 204,
|
||||||
security: 'adminApiKey',
|
security: 'adminApiKey',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@app/api/channel/services/message/MessageResponseDataService';
|
} from '@app/api/channel/services/message/MessageResponseDataService';
|
||||||
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
|
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
|
||||||
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
||||||
|
import type {IARReportNoteRow} from '@app/api/database/types/ReportTypes';
|
||||||
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
|
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
|
||||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||||
@@ -32,6 +33,7 @@ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
|||||||
import {describeReportFlowAnswers} from '@app/api/report/flows/ReportFlowRegistry';
|
import {describeReportFlowAnswers} from '@app/api/report/flows/ReportFlowRegistry';
|
||||||
import {findReportReason, listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
|
import {findReportReason, listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
|
||||||
import type {IARMessageContext, IARSubmission} from '@app/api/report/ReportModels';
|
import type {IARMessageContext, IARSubmission} from '@app/api/report/ReportModels';
|
||||||
|
import type {ReportRepository} from '@app/api/report/ReportRepository';
|
||||||
import type {ReportService} from '@app/api/report/ReportService';
|
import type {ReportService} from '@app/api/report/ReportService';
|
||||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||||
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
|
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
|
||||||
@@ -44,6 +46,7 @@ import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/F
|
|||||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||||
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
|
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
|
||||||
import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||||
|
import type {ReportNoteListResponse, ReportNoteResponse} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
|
||||||
import type {
|
import type {
|
||||||
AdminReportReasonsResponse,
|
AdminReportReasonsResponse,
|
||||||
ReportItemOutcome,
|
ReportItemOutcome,
|
||||||
@@ -58,6 +61,7 @@ import {seconds} from 'itty-time';
|
|||||||
interface AdminReportServiceDeps {
|
interface AdminReportServiceDeps {
|
||||||
apiContext: ApiContext;
|
apiContext: ApiContext;
|
||||||
reportService: ReportService;
|
reportService: ReportService;
|
||||||
|
reportRepository: ReportRepository;
|
||||||
guildRepository: GuildRepository;
|
guildRepository: GuildRepository;
|
||||||
channelRepository: ChannelRepository;
|
channelRepository: ChannelRepository;
|
||||||
storageService: IStorageService;
|
storageService: IStorageService;
|
||||||
@@ -136,6 +140,15 @@ function mapMissingAttachmentToResponse(attachment: MessageAttachment) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toReportNoteResponse(note: IARReportNoteRow): ReportNoteResponse {
|
||||||
|
return {
|
||||||
|
note_id: note.note_id.toString(),
|
||||||
|
author_id: note.author_id.toString(),
|
||||||
|
body: note.body,
|
||||||
|
created_at: note.created_at.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mapReportReasonFields(report: IARSubmission) {
|
function mapReportReasonFields(report: IARSubmission) {
|
||||||
const reason = report.reason ? findReportReason(report.reason) : null;
|
const reason = report.reason ? findReportReason(report.reason) : null;
|
||||||
const flow =
|
const flow =
|
||||||
@@ -197,7 +210,30 @@ export class AdminReportService {
|
|||||||
const report = await reportService.getReport(reportId);
|
const report = await reportService.getReport(reportId);
|
||||||
const requestCache = createRequestCache();
|
const requestCache = createRequestCache();
|
||||||
const reportLookupCache = createReportLookupCache();
|
const reportLookupCache = createReportLookupCache();
|
||||||
return this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache);
|
const [response, notes] = await Promise.all([
|
||||||
|
this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache),
|
||||||
|
this.deps.reportRepository.listNotes(reportId),
|
||||||
|
]);
|
||||||
|
return {...response, notes: notes.map(toReportNoteResponse)};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listNotes(reportId: ReportID): Promise<ReportNoteListResponse> {
|
||||||
|
await this.deps.reportService.getReport(reportId);
|
||||||
|
const notes = await this.deps.reportRepository.listNotes(reportId);
|
||||||
|
return {notes: notes.map(toReportNoteResponse)};
|
||||||
|
}
|
||||||
|
|
||||||
|
async addNote(reportId: ReportID, body: string, authorId: UserID): Promise<ReportNoteResponse> {
|
||||||
|
await this.deps.reportService.getReport(reportId);
|
||||||
|
const note: IARReportNoteRow = {
|
||||||
|
report_id: reportId,
|
||||||
|
note_id: await this.deps.apiContext.services.snowflake.generate(),
|
||||||
|
author_id: authorId,
|
||||||
|
body,
|
||||||
|
created_at: new Date(),
|
||||||
|
};
|
||||||
|
await this.deps.reportRepository.addNote(note);
|
||||||
|
return toReportNoteResponse(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveReport(
|
async resolveReport(
|
||||||
|
|||||||
@@ -234,6 +234,25 @@ describe('DELETE /admin/reports/:report_id', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deletes the notes with the report and keeps the notes of other reports', async () => {
|
||||||
|
const deleted = await seedMessageReport(newEvidence());
|
||||||
|
const other = await seedMessageReport(newEvidence());
|
||||||
|
for (const seeded of [deleted, other]) {
|
||||||
|
await repository.addNote({
|
||||||
|
report_id: seeded.reportId,
|
||||||
|
note_id: nextId(),
|
||||||
|
author_id: BigInt(admin.userId),
|
||||||
|
body: 'Staff context',
|
||||||
|
created_at: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteRequest(admin, deleted.reportId).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||||
|
|
||||||
|
expect(await repository.listNotes(deleted.reportId)).toEqual([]);
|
||||||
|
expect(await repository.listNotes(other.reportId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps an evidence copy another report still uses', async () => {
|
test('keeps an evidence copy another report still uses', async () => {
|
||||||
const shared = newEvidence();
|
const shared = newEvidence();
|
||||||
const deleted = await seedMessageReport(shared);
|
const deleted = await seedMessageReport(shared);
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import type {AdminAuditLog} from '@app/api/admin/AdminRepository';
|
||||||
|
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||||
|
import {createReportID, type ReportID} from '@app/api/BrandedTypes';
|
||||||
|
import {IAR_SUBMISSION_COLUMNS, type IARSubmissionRow} from '@app/api/database/types/ReportTypes';
|
||||||
|
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||||
|
import {ReportRepository} from '@app/api/report/ReportRepository';
|
||||||
|
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||||
|
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||||
|
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||||
|
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||||
|
import type {ReportNoteListResponse, ReportNoteResponse} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
|
||||||
|
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||||
|
|
||||||
|
interface ValidationErrorResponse {
|
||||||
|
code: string;
|
||||||
|
errors?: Array<{path: string; message: string}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUDIT_REASON = 'Ticket 314';
|
||||||
|
let sequence = 1_496_000_000_000_000_000n;
|
||||||
|
|
||||||
|
describe('report notes', () => {
|
||||||
|
let harness: ApiTestHarness;
|
||||||
|
let admin: TestAccount;
|
||||||
|
let repository: ReportRepository;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createApiTestHarness();
|
||||||
|
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||||
|
'admin:authenticate',
|
||||||
|
AdminACLs.REPORT_VIEW,
|
||||||
|
AdminACLs.REPORT_RESOLVE,
|
||||||
|
]);
|
||||||
|
repository = new ReportRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness?.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedReport(): Promise<ReportID> {
|
||||||
|
sequence += 1n;
|
||||||
|
await repository.createReport({
|
||||||
|
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
|
||||||
|
report_id: sequence,
|
||||||
|
reported_at: new Date(),
|
||||||
|
status: 0,
|
||||||
|
report_type: 1,
|
||||||
|
category: 'harassment',
|
||||||
|
} as IARSubmissionRow);
|
||||||
|
return createReportID(sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNote(account: TestAccount, reportId: ReportID | string, body: unknown) {
|
||||||
|
return createBuilder<ReportNoteResponse & ValidationErrorResponse>(harness, account.token)
|
||||||
|
.post(`/admin/reports/${reportId}/notes`)
|
||||||
|
.header('X-Audit-Log-Reason', AUDIT_REASON)
|
||||||
|
.body({body});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listNotes(account: TestAccount, reportId: ReportID | string) {
|
||||||
|
return createBuilder<ReportNoteListResponse>(harness, account.token).get(`/admin/reports/${reportId}/notes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditEntries(action: string): Promise<Array<AdminAuditLog>> {
|
||||||
|
const logs = await getAdminRepository().listAllAuditLogsPaginated(10000);
|
||||||
|
return logs.filter((log) => log.action === action);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('adds notes, lists them oldest first and returns them with the report', async () => {
|
||||||
|
const reportId = await seedReport();
|
||||||
|
|
||||||
|
const first = await addNote(admin, reportId, ' Reporter sent more context by email ')
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
const second = await addNote(admin, reportId, 'Waiting on the guild owner').expect(HTTP_STATUS.OK).execute();
|
||||||
|
|
||||||
|
expect(first).toEqual({
|
||||||
|
note_id: expect.any(String),
|
||||||
|
author_id: admin.userId,
|
||||||
|
body: 'Reporter sent more context by email',
|
||||||
|
created_at: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(await listNotes(admin, reportId).expect(HTTP_STATUS.OK).execute()).toEqual({notes: [first, second]});
|
||||||
|
const detail = await createBuilder<{notes?: Array<ReportNoteResponse>}>(harness, admin.token)
|
||||||
|
.get(`/admin/reports/${reportId}`)
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
expect(detail.notes).toEqual([first, second]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('records the note in the audit log without its body', async () => {
|
||||||
|
const reportId = await seedReport();
|
||||||
|
|
||||||
|
const note = await addNote(admin, reportId, 'Private staff context').expect(HTTP_STATUS.OK).execute();
|
||||||
|
|
||||||
|
const [entry, ...rest] = await auditEntries('annotate_report');
|
||||||
|
expect(rest).toEqual([]);
|
||||||
|
expect(entry?.adminUserId.toString()).toBe(admin.userId);
|
||||||
|
expect(entry?.targetType).toBe('report');
|
||||||
|
expect(entry?.targetId.toString()).toBe(reportId.toString());
|
||||||
|
expect(entry?.auditLogReason).toBe(AUDIT_REASON);
|
||||||
|
expect(Object.fromEntries(entry?.metadata ?? [])).toEqual({
|
||||||
|
report_id: reportId.toString(),
|
||||||
|
note_id: note.note_id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['empty', ''],
|
||||||
|
['blank', ' '],
|
||||||
|
['too long', 'x'.repeat(2001)],
|
||||||
|
['not a string', 42],
|
||||||
|
])('refuses a %s body and stores nothing', async (_, body) => {
|
||||||
|
const reportId = await seedReport();
|
||||||
|
|
||||||
|
const response = await addNote(admin, reportId, body)
|
||||||
|
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
expect(response.errors?.[0]?.path).toBe('body');
|
||||||
|
expect(await repository.listNotes(reportId)).toEqual([]);
|
||||||
|
expect(await auditEntries('annotate_report')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a body of 2000 characters', async () => {
|
||||||
|
const reportId = await seedReport();
|
||||||
|
|
||||||
|
const note = await addNote(admin, reportId, 'x'.repeat(2000)).expect(HTTP_STATUS.OK).execute();
|
||||||
|
|
||||||
|
expect(note.body).toHaveLength(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown report gives UNKNOWN_REPORT and stores nothing', async () => {
|
||||||
|
sequence += 1n;
|
||||||
|
const missing = createReportID(sequence);
|
||||||
|
|
||||||
|
await addNote(admin, missing, 'Note').expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_REPORT').execute();
|
||||||
|
await listNotes(admin, missing).expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_REPORT').execute();
|
||||||
|
|
||||||
|
expect(await repository.listNotes(missing)).toEqual([]);
|
||||||
|
expect(await auditEntries('annotate_report')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adding a note needs REPORT_RESOLVE', async () => {
|
||||||
|
const reportId = await seedReport();
|
||||||
|
const viewer = await setUserACLs(harness, await createTestAccount(harness), [
|
||||||
|
'admin:authenticate',
|
||||||
|
AdminACLs.REPORT_VIEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await addNote(viewer, reportId, 'Note').expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL').execute();
|
||||||
|
|
||||||
|
expect(await repository.listNotes(reportId)).toEqual([]);
|
||||||
|
expect(await auditEntries('annotate_report')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listing notes needs REPORT_VIEW', async () => {
|
||||||
|
const reportId = await seedReport();
|
||||||
|
await addNote(admin, reportId, 'Note').expect(HTTP_STATUS.OK).execute();
|
||||||
|
const resolver = await setUserACLs(harness, await createTestAccount(harness), [
|
||||||
|
'admin:authenticate',
|
||||||
|
AdminACLs.REPORT_RESOLVE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await listNotes(resolver, reportId).expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL').execute();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {createReportID} from '@app/api/BrandedTypes';
|
|||||||
import {getReportRepository} from '@app/api/middleware/ServiceSingletons';
|
import {getReportRepository} from '@app/api/middleware/ServiceSingletons';
|
||||||
import {listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
|
import {listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
|
||||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||||
|
import {expect} from 'vitest';
|
||||||
|
|
||||||
interface FiledUserReport {
|
interface FiledUserReport {
|
||||||
reportId: string;
|
reportId: string;
|
||||||
@@ -321,6 +322,42 @@ export const ReportAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
route: '/admin/reports/:report_id/notes',
|
||||||
|
async prepare(context) {
|
||||||
|
const {reportId} = await fileUserReport(context);
|
||||||
|
await createBuilder(context.harness, context.admin.token)
|
||||||
|
.post(`/admin/reports/${reportId}/notes`)
|
||||||
|
.body({body: 'Coverage note'})
|
||||||
|
.execute();
|
||||||
|
return {
|
||||||
|
request: {path: `/admin/reports/${reportId}/notes`},
|
||||||
|
expected: {
|
||||||
|
action: 'list_report_notes',
|
||||||
|
targetType: 'report',
|
||||||
|
targetId: reportId,
|
||||||
|
metadata: {result_count: '1'},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
route: '/admin/reports/:report_id/notes',
|
||||||
|
async prepare(context) {
|
||||||
|
const {reportId} = await fileUserReport(context);
|
||||||
|
return {
|
||||||
|
request: {path: `/admin/reports/${reportId}/notes`, body: {body: 'Coverage note'}},
|
||||||
|
expected: {
|
||||||
|
action: 'annotate_report',
|
||||||
|
targetType: 'report',
|
||||||
|
targetId: reportId,
|
||||||
|
metadata: {report_id: reportId, note_id: expect.any(String)},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
route: '/admin/reports/:report_id/legal-hold',
|
route: '/admin/reports/:report_id/legal-hold',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DSAReportEmailVerifications,
|
DSAReportEmailVerifications,
|
||||||
DSAReportTickets,
|
DSAReportTickets,
|
||||||
GuildReportSubmissionsByReporter,
|
GuildReportSubmissionsByReporter,
|
||||||
|
IARReportNotes,
|
||||||
IARSubmissions,
|
IARSubmissions,
|
||||||
MessageReportSubmissionsByReporter,
|
MessageReportSubmissionsByReporter,
|
||||||
UserReportSubmissionsByReporter,
|
UserReportSubmissionsByReporter,
|
||||||
@@ -52,6 +53,7 @@ const IAR_MESSAGE_CONTEXT_FIELDS = {
|
|||||||
|
|
||||||
const REPORT_TABLES = [
|
const REPORT_TABLES = [
|
||||||
IARSubmissions,
|
IARSubmissions,
|
||||||
|
IARReportNotes,
|
||||||
MessageReportSubmissionsByReporter,
|
MessageReportSubmissionsByReporter,
|
||||||
UserReportSubmissionsByReporter,
|
UserReportSubmissionsByReporter,
|
||||||
GuildReportSubmissionsByReporter,
|
GuildReportSubmissionsByReporter,
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ export interface IARSubmissionRow {
|
|||||||
reporter_outcome?: string | null;
|
reporter_outcome?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IARReportNoteRow {
|
||||||
|
report_id: bigint;
|
||||||
|
note_id: bigint;
|
||||||
|
author_id: bigint;
|
||||||
|
body: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DSAReportEmailVerificationRow {
|
export interface DSAReportEmailVerificationRow {
|
||||||
email_lower: string;
|
email_lower: string;
|
||||||
code_hash: string;
|
code_hash: string;
|
||||||
@@ -186,6 +194,13 @@ export const IAR_SUBMISSION_COLUMNS = [
|
|||||||
'legal_hold_reason',
|
'legal_hold_reason',
|
||||||
'reporter_outcome',
|
'reporter_outcome',
|
||||||
] as const satisfies ReadonlyArray<keyof IARSubmissionRow>;
|
] as const satisfies ReadonlyArray<keyof IARSubmissionRow>;
|
||||||
|
export const IAR_REPORT_NOTE_COLUMNS = [
|
||||||
|
'report_id',
|
||||||
|
'note_id',
|
||||||
|
'author_id',
|
||||||
|
'body',
|
||||||
|
'created_at',
|
||||||
|
] as const satisfies ReadonlyArray<keyof IARReportNoteRow>;
|
||||||
export const DSA_REPORT_EMAIL_VERIFICATION_COLUMNS = [
|
export const DSA_REPORT_EMAIL_VERIFICATION_COLUMNS = [
|
||||||
'email_lower',
|
'email_lower',
|
||||||
'code_hash',
|
'code_hash',
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ import {
|
|||||||
createUserID,
|
createUserID,
|
||||||
createWebhookID,
|
createWebhookID,
|
||||||
} from '@app/api/BrandedTypes';
|
} from '@app/api/BrandedTypes';
|
||||||
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
import {
|
||||||
|
deleteOneOrMany,
|
||||||
|
executeConditional,
|
||||||
|
fetchMany,
|
||||||
|
fetchOne,
|
||||||
|
upsertOne,
|
||||||
|
} from '@app/api/database/CassandraQueryExecution';
|
||||||
import {Db} from '@app/api/database/CassandraTypes';
|
import {Db} from '@app/api/database/CassandraTypes';
|
||||||
import type {
|
import type {
|
||||||
DSAReportEmailVerificationRow,
|
DSAReportEmailVerificationRow,
|
||||||
DSAReportTicketRow,
|
DSAReportTicketRow,
|
||||||
GuildReportSubmissionByReporterRow,
|
GuildReportSubmissionByReporterRow,
|
||||||
|
IARReportNoteRow,
|
||||||
MessageReportSubmissionByReporterRow,
|
MessageReportSubmissionByReporterRow,
|
||||||
UserReportSubmissionByReporterRow,
|
UserReportSubmissionByReporterRow,
|
||||||
} from '@app/api/database/types/ReportTypes';
|
} from '@app/api/database/types/ReportTypes';
|
||||||
@@ -32,6 +39,7 @@ import {
|
|||||||
DSAReportEmailVerifications,
|
DSAReportEmailVerifications,
|
||||||
DSAReportTickets,
|
DSAReportTickets,
|
||||||
GuildReportSubmissionsByReporter,
|
GuildReportSubmissionsByReporter,
|
||||||
|
IARReportNotes,
|
||||||
IARSubmissions,
|
IARSubmissions,
|
||||||
MessageReportSubmissionsByReporter,
|
MessageReportSubmissionsByReporter,
|
||||||
UserReportSubmissionsByReporter,
|
UserReportSubmissionsByReporter,
|
||||||
@@ -45,6 +53,10 @@ const GET_REPORT_QUERY = IARSubmissions.select({
|
|||||||
where: IARSubmissions.where.eq('report_id'),
|
where: IARSubmissions.where.eq('report_id'),
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
const LIST_NOTES_QUERY = IARReportNotes.select({
|
||||||
|
where: IARReportNotes.where.eq('report_id'),
|
||||||
|
orderBy: {col: 'note_id', direction: 'ASC'},
|
||||||
|
});
|
||||||
const createFetchAllReportsPaginatedQuery = (limit: number) =>
|
const createFetchAllReportsPaginatedQuery = (limit: number) =>
|
||||||
IARSubmissions.select({
|
IARSubmissions.select({
|
||||||
where: IARSubmissions.where.tokenGt('report_id', 'last_report_id'),
|
where: IARSubmissions.where.tokenGt('report_id', 'last_report_id'),
|
||||||
@@ -216,11 +228,20 @@ export class ReportRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.releaseReporterReservation(report);
|
await this.releaseReporterReservation(report);
|
||||||
|
await deleteOneOrMany(IARReportNotes.deletePartition({report_id: reportId}));
|
||||||
await executeConditional(
|
await executeConditional(
|
||||||
IARSubmissions.conditionalDeleteByPk({report_id: reportId}, {report_type: report.reportType}),
|
IARSubmissions.conditionalDeleteByPk({report_id: reportId}, {report_type: report.reportType}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addNote(note: IARReportNoteRow): Promise<void> {
|
||||||
|
await upsertOne(IARReportNotes.insert(note));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listNotes(reportId: ReportID): Promise<Array<IARReportNoteRow>> {
|
||||||
|
return fetchMany<IARReportNoteRow>(LIST_NOTES_QUERY.bind({report_id: reportId}));
|
||||||
|
}
|
||||||
|
|
||||||
private async releaseReporterReservation(report: IARSubmission): Promise<void> {
|
private async releaseReporterReservation(report: IARSubmission): Promise<void> {
|
||||||
const {reporterId, reportId, reportedAt} = report;
|
const {reporterId, reportId, reportedAt} = report;
|
||||||
if (!reporterId) {
|
if (!reporterId) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|||||||
import {fileURLToPath} from 'node:url';
|
import {fileURLToPath} from 'node:url';
|
||||||
import {clearTestEmails, createUniqueEmail, findLastTestEmail, listTestEmails} from '@app/api/auth/tests/AuthTestUtils';
|
import {clearTestEmails, createUniqueEmail, findLastTestEmail, listTestEmails} from '@app/api/auth/tests/AuthTestUtils';
|
||||||
import {ReportRepository} from '@app/api/report/ReportRepository';
|
import {ReportRepository} from '@app/api/report/ReportRepository';
|
||||||
import {DSAReportEmailVerifications, DSAReportTickets, IARSubmissions} from '@app/api/Tables';
|
import {DSAReportEmailVerifications, DSAReportTickets, IARReportNotes, IARSubmissions} from '@app/api/Tables';
|
||||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||||
@@ -29,6 +29,11 @@ describe('report table TTLs', () => {
|
|||||||
expect(IARSubmissions.defaultTtlSeconds).toBeUndefined();
|
expect(IARSubmissions.defaultTtlSeconds).toBeUndefined();
|
||||||
expect(schemaOptions(IARSubmissions.name)).not.toContain('default_time_to_live');
|
expect(schemaOptions(IARSubmissions.name)).not.toContain('default_time_to_live');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps report notes without a table TTL, so they go only with the report', () => {
|
||||||
|
expect(IARReportNotes.defaultTtlSeconds).toBeUndefined();
|
||||||
|
expect(schemaOptions(IARReportNotes.name)).not.toContain('default_time_to_live');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('report table TTLs cover the service windows', () => {
|
describe('report table TTLs cover the service windows', () => {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export function createWorkerEnforcementServices(deps: WorkerDependencies): Worke
|
|||||||
adminReportService: new AdminReportService({
|
adminReportService: new AdminReportService({
|
||||||
apiContext,
|
apiContext,
|
||||||
reportService,
|
reportService,
|
||||||
|
reportRepository: deps.reportRepository,
|
||||||
guildRepository: deps.guildRepository,
|
guildRepository: deps.guildRepository,
|
||||||
channelRepository: deps.channelRepository,
|
channelRepository: deps.channelRepository,
|
||||||
storageService: deps.storageService,
|
storageService: deps.storageService,
|
||||||
|
|||||||
@@ -354,6 +354,43 @@ describe('report retention', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deletes the notes with an expired report and keeps the notes of a retained one', async () => {
|
||||||
|
const expired = await seed({resolvedDaysAgo: 365});
|
||||||
|
const contextExpired = await seed({resolvedDaysAgo: 200, outcome: 'no_violation', additionalInfo: 'details'});
|
||||||
|
const kept = await seed({resolvedDaysAgo: 10});
|
||||||
|
for (const {reportId} of [expired, contextExpired, kept]) {
|
||||||
|
await repository.addNote({
|
||||||
|
report_id: reportId,
|
||||||
|
note_id: nextId(),
|
||||||
|
author_id: nextId(),
|
||||||
|
body: 'Staff context',
|
||||||
|
created_at: NOW,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await run();
|
||||||
|
|
||||||
|
expect(summary).toMatchObject({deleted: 1, contextCleared: 1});
|
||||||
|
expect(await repository.listNotes(expired.reportId)).toEqual([]);
|
||||||
|
expect(await repository.listNotes(contextExpired.reportId)).toHaveLength(1);
|
||||||
|
expect(await repository.listNotes(kept.reportId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the notes in a dry run', async () => {
|
||||||
|
const expired = await seed({resolvedDaysAgo: 365});
|
||||||
|
await repository.addNote({
|
||||||
|
report_id: expired.reportId,
|
||||||
|
note_id: nextId(),
|
||||||
|
author_id: nextId(),
|
||||||
|
body: 'Staff context',
|
||||||
|
created_at: NOW,
|
||||||
|
});
|
||||||
|
|
||||||
|
await run({dryRun: true});
|
||||||
|
|
||||||
|
expect(await repository.listNotes(expired.reportId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps a pending report however old it is', async () => {
|
it('keeps a pending report however old it is', async () => {
|
||||||
const pending = await seed({ageDays: 4000, attachment: newAttachment(), profile: true});
|
const pending = await seed({ageDays: 4000, attachment: newAttachment(), profile: true});
|
||||||
|
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ An entry with one of these actions has `access` set to `read`.
|
|||||||
| list_guild_stickers | The stickers of a guild were listed |
|
| list_guild_stickers | The stickers of a guild were listed |
|
||||||
| list_guild_threads | The threads of a guild were listed |
|
| list_guild_threads | The threads of a guild were listed |
|
||||||
| list_pending_registrations | Pending registrations were read |
|
| list_pending_registrations | Pending registrations were read |
|
||||||
|
| list_report_notes | The internal notes on a report were listed |
|
||||||
| list_report_reasons | The report reason catalogue was listed |
|
| list_report_reasons | The report reason catalogue was listed |
|
||||||
| list_user_applications | The applications an account owns were read |
|
| list_user_applications | The applications an account owns were read |
|
||||||
| list_user_change_log | The contact change log of an account was read |
|
| list_user_change_log | The contact change log of an account was read |
|
||||||
@@ -316,6 +317,7 @@ An entry with any other action has `access` set to `write`.
|
|||||||
| Value | Description |
|
| Value | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| annotate_enforcement_decision | A note was added to an enforcement decision |
|
| annotate_enforcement_decision | A note was added to an enforcement decision |
|
||||||
|
| annotate_report | A note was added to a report |
|
||||||
| approve_discovery_application | A discovery application was approved |
|
| approve_discovery_application | A discovery application was approved |
|
||||||
| approve_registration | A pending registration was approved |
|
| approve_registration | A pending registration was approved |
|
||||||
| auto_resolve_reports_on_deletion | The pending reports against an account were processed as part of its deletion |
|
| auto_resolve_reports_on_deletion | The pending reports against an account were processed as part of its deletion |
|
||||||
@@ -589,8 +591,8 @@ The registry is returned in this order by [List ACLs](#list-acls). A value outsi
|
|||||||
| message:shred | Queues an explicit message shred job and reads its state |
|
| message:shred | Queues an explicit message shred job and reads its state |
|
||||||
| registration:review | Reads and decides [pending registrations](/admin-api/instance/#list-pending-registrations) |
|
| registration:review | Reads and decides [pending registrations](/admin-api/instance/#list-pending-registrations) |
|
||||||
| report:delete | Deletes a [report](/admin-api/reports/#delete-report) with its stored evidence |
|
| report:delete | Deletes a [report](/admin-api/reports/#delete-report) with its stored evidence |
|
||||||
| report:resolve | Updates and resolves [reports](/admin-api/reports/), and places legal holds on them |
|
| report:resolve | Updates and resolves [reports](/admin-api/reports/), adds notes to them, and places legal holds on them |
|
||||||
| report:view | Reads and searches reports |
|
| report:view | Reads and searches reports and their notes |
|
||||||
| report:view:reporter_pii<sup>6</sup> | Unredacts the contact fields a reporter supplied |
|
| report:view:reporter_pii<sup>6</sup> | Unredacts the contact fields a reporter supplied |
|
||||||
| system_dm:send | Sends an official direct message from the instance to one or many accounts |
|
| system_dm:send | Sends an official direct message from the instance to one or many accounts |
|
||||||
| user:cancel:bulk_message_deletion | Cancels the bulk message deletion an account scheduled for itself |
|
| user:cancel:bulk_message_deletion | Cancels the bulk message deletion an account scheduled for itself |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: Report listing and search, captured evidence, report resolution, re
|
|||||||
|
|
||||||
import RouteHeader from '@/components/RouteHeader.astro';
|
import RouteHeader from '@/components/RouteHeader.astro';
|
||||||
|
|
||||||
A report is one complaint filed against a message, an account, or a guild, together with the evidence captured when it was filed. These routes list and search reports, return that evidence, resolve a report with a comment shown to the reporter, hold a report past its retention period, and delete a report before it.
|
A report is one complaint filed against a message, an account, or a guild, together with the evidence captured when it was filed. These routes list and search reports, return that evidence, resolve a report with a comment shown to the reporter, keep internal notes on a report, hold a report past its retention period, and delete a report before it.
|
||||||
|
|
||||||
Report submission belongs to the public [Reports](/http-api/reports/) resource, which defines the [report categories](/http-api/reports/#report-categories) and the [report reasons](/http-api/reports/#report-reasons) a report is filed under.
|
Report submission belongs to the public [Reports](/http-api/reports/) resource, which defines the [report categories](/http-api/reports/#report-categories) and the [report reasons](/http-api/reports/#report-reasons) a report is filed under.
|
||||||
|
|
||||||
@@ -201,6 +201,7 @@ The detail object extends the [Admin report object](#admin-report-object) with t
|
|||||||
| reported_profile_snapshot?<sup>4</sup> | ?[report profile snapshot](#report-profile-snapshot-object) object | The reported profile as it was when the report was filed |
|
| reported_profile_snapshot?<sup>4</sup> | ?[report profile snapshot](#report-profile-snapshot-object) object | The reported profile as it was when the report was filed |
|
||||||
| legal_hold_until?<sup>5</sup> | ?ISO8601 timestamp | When the legal hold on the report ends |
|
| legal_hold_until?<sup>5</sup> | ?ISO8601 timestamp | When the legal hold on the report ends |
|
||||||
| legal_hold_reason?<sup>5</sup> | ?string | Why the report is held |
|
| legal_hold_reason?<sup>5</sup> | ?string | Why the report is held |
|
||||||
|
| notes?<sup>6</sup> | array[[report note](#report-note-object) object] | The internal notes on the report, oldest first |
|
||||||
|
|
||||||
<sup>1</sup> Set only for a USER report that has both a reporter and a reported account, and null when the two share no direct message channel
|
<sup>1</sup> Set only for a USER report that has both a reporter and a reported account, and null when the two share no direct message channel
|
||||||
|
|
||||||
@@ -212,6 +213,8 @@ The detail object extends the [Admin report object](#admin-report-object) with t
|
|||||||
|
|
||||||
<sup>5</sup> Always emitted. Null when the report has no hold. [Set report legal hold](#set-report-legal-hold) places and clears the hold, and [Report retention](#report-retention) describes its effect
|
<sup>5</sup> Always emitted. Null when the report has no hold. [Set report legal hold](#set-report-legal-hold) places and clears the hold, and [Report retention](#report-retention) describes its effect
|
||||||
|
|
||||||
|
<sup>6</sup> Always emitted. Empty when the report has no notes
|
||||||
|
|
||||||
:::note[The live array can be shorter than the capture]
|
:::note[The live array can be shorter than the capture]
|
||||||
Fluxer skips an entry when neither the capture nor the report records its channel. It also omits a message that no longer exists, or whose channel can no longer be read.
|
Fluxer skips an entry when neither the capture nor the report records its channel. It also omits a message that no longer exists, or whose channel can no longer be read.
|
||||||
:::
|
:::
|
||||||
@@ -519,9 +522,33 @@ A report resolution is the reduced view [Update report](#update-report) returns.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Report note object
|
||||||
|
|
||||||
|
A note is internal context a staff member adds to a report. Notes are staff only. Fluxer never shows them to the reporter or the reported account, and never puts them in a notice, email or direct message. A note never changes once it is written. Notes have no retention period of their own and are deleted together with the report.
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| note_id | snowflake | The ID of the note |
|
||||||
|
| author_id | snowflake | The staff member who wrote it |
|
||||||
|
| body | string | The note |
|
||||||
|
| created_at | ISO8601 timestamp | When it was written |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"note_id": "1502771904419102999",
|
||||||
|
"author_id": "1483920011884392001",
|
||||||
|
"body": "Reporter sent more context by email",
|
||||||
|
"created_at": "2026-03-05T09:14:02.118Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Report retention
|
## Report retention
|
||||||
|
|
||||||
A pending report is kept until it is resolved. Once a report is resolved, Fluxer deletes it when its retention period ends, together with its stored evidence: the captured message context, the profile snapshot and its images, the attachment copies, and its search index entry. The reporter's duplicate-report reservation for it goes too, so the reporter can report the same message, account or guild again.
|
A pending report is kept until it is resolved. Once a report is resolved, Fluxer deletes it when its retention period ends, together with its notes and its stored evidence: the captured message context, the profile snapshot and its images, the attachment copies, and its search index entry. The reporter's duplicate-report reservation for it goes too, so the reporter can report the same message, account or guild again.
|
||||||
|
|
||||||
The retention period ends 365 days after `resolved_at`, or the number of days in `FLUXER_REPORT_RETENTION_DAYS`. Some reports are kept longer, whatever that setting says:
|
The retention period ends 365 days after `resolved_at`, or the number of days in `FLUXER_REPORT_RETENTION_DAYS`. Some reports are kept longer, whatever that setting says:
|
||||||
|
|
||||||
@@ -529,7 +556,7 @@ The retention period ends 365 days after `resolved_at`, or the number of days in
|
|||||||
- A report listed in the `report_ids` of an [enforcement decision](/admin-api/enforcement/#enforcement-decision-object) is kept for at least 365 days after the newest such decision was recorded, as long as the decision's evidence.
|
- A report listed in the `report_ids` of an [enforcement decision](/admin-api/enforcement/#enforcement-decision-object) is kept for at least 365 days after the newest such decision was recorded, as long as the decision's evidence.
|
||||||
- A report with a [legal hold](#set-report-legal-hold) that ends later is kept until the next run after the hold ends.
|
- A report with a [legal hold](#set-report-legal-hold) that ends later is kept until the next run after the hold ends.
|
||||||
|
|
||||||
A report resolved with the outcome `no_violation` loses its copied context 184 days after it was resolved, which covers the six months the Digital Services Act gives a reporter to contest the outcome. Fluxer then removes the captured message context, the profile snapshot, the reporter's additional information, and the stored evidence copies, and keeps the rest of the record and its search index entry until the end of the retention period. This does not apply to a `child_safety` report, to a report linked to an enforcement decision, or while a legal hold has not ended.
|
A report resolved with the outcome `no_violation` loses its copied context 184 days after it was resolved, which covers the six months the Digital Services Act gives a reporter to contest the outcome. Fluxer then removes the captured message context, the profile snapshot, the reporter's additional information, and the stored evidence copies, and keeps the rest of the record, its notes and its search index entry until the end of the retention period. This does not apply to a `child_safety` report, to a report linked to an enforcement decision, or while a legal hold has not ended.
|
||||||
|
|
||||||
An evidence copy that a retained report also uses is kept for that report.
|
An evidence copy that a retained report also uses is kept for that report.
|
||||||
|
|
||||||
@@ -638,7 +665,7 @@ The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-obje
|
|||||||
|
|
||||||
<RouteHeader method="GET" path="/v1/admin/reports/{report_id}" auditReason />
|
<RouteHeader method="GET" path="/v1/admin/reports/{report_id}" auditReason />
|
||||||
|
|
||||||
Returns one [Admin report detail](#admin-report-detail-object) object, which adds the captured message context and the live state of those messages. Requires `report:view`.
|
Returns one [Admin report detail](#admin-report-detail-object) object, which adds the captured message context, the live state of those messages, and the notes on the report. Requires `report:view`.
|
||||||
|
|
||||||
The operation answers even when the report search index is unavailable. Reporter contact fields are redacted here exactly as they are in [List reports](#list-reports).
|
The operation answers even when the report search index is unavailable. Reporter contact fields are redacted here exactly as they are in [List reports](#list-reports).
|
||||||
|
|
||||||
@@ -729,6 +756,73 @@ A reporter who does not already have the system channel open receives [Channel C
|
|||||||
|
|
||||||
200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
|
200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
|
||||||
|
|
||||||
|
## List report notes
|
||||||
|
|
||||||
|
<RouteHeader method="GET" path="/v1/admin/reports/{report_id}/notes" auditReason />
|
||||||
|
|
||||||
|
Returns the [notes](#report-note-object) on one report, oldest first. Requires `report:view`. [Get report](#get-report) returns the same notes in `notes`.
|
||||||
|
|
||||||
|
### Path parameters
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| report_id | snowflake | The ID of the report |
|
||||||
|
|
||||||
|
### Response body
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| notes | array[[report note](#report-note-object) object] | The notes on the report, oldest first |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
| Status | Body | Condition |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 200 | response body | The notes were returned |
|
||||||
|
| 404 | [error response](/admin-api/#error-response) | Report does not exist and the request returns `UNKNOWN_REPORT` |
|
||||||
|
|
||||||
|
### Side effects
|
||||||
|
|
||||||
|
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `list_report_notes`, target type `report`, the report ID as target ID, and the metadata key `result_count`. A 404 records nothing.
|
||||||
|
|
||||||
|
### Rate limit
|
||||||
|
|
||||||
|
200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
|
||||||
|
|
||||||
|
## Add a note to a report
|
||||||
|
|
||||||
|
<RouteHeader method="POST" path="/v1/admin/reports/{report_id}/notes" auditReason />
|
||||||
|
|
||||||
|
Appends an internal note to one report and returns the [report note](#report-note-object) object. Requires `report:resolve`. A note can be added to a pending or a resolved report.
|
||||||
|
|
||||||
|
### Path parameters
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| report_id | snowflake | The ID of the report |
|
||||||
|
|
||||||
|
### JSON body
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| body | string | The note (1-2000 characters) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
| Status | Body | Condition |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 200 | [report note](#report-note-object) object | The note was added |
|
||||||
|
| 400 | [error response](/admin-api/#error-response) | `INVALID_FORM_BODY`, because `body` is empty or longer than 2000 characters |
|
||||||
|
| 404 | [error response](/admin-api/#error-response) | Report does not exist and the request returns `UNKNOWN_REPORT` |
|
||||||
|
|
||||||
|
### Side effects
|
||||||
|
|
||||||
|
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `annotate_report`, target type `report`, the report ID as target ID, and the metadata keys `report_id` and `note_id`. The note text is not recorded. A rejected request records nothing.
|
||||||
|
|
||||||
|
### Rate limit
|
||||||
|
|
||||||
|
100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
|
||||||
|
|
||||||
## Set report legal hold
|
## Set report legal hold
|
||||||
|
|
||||||
<RouteHeader method="POST" path="/v1/admin/reports/{report_id}/legal-hold" auditReason />
|
<RouteHeader method="POST" path="/v1/admin/reports/{report_id}/legal-hold" auditReason />
|
||||||
@@ -776,7 +870,7 @@ Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) wit
|
|||||||
|
|
||||||
Deletes one report straight away. Requires `report:delete`, which `report:resolve` does not include.
|
Deletes one report straight away. Requires `report:delete`, which `report:resolve` does not include.
|
||||||
|
|
||||||
This is the deletion [report retention](#report-retention) runs at the end of the retention period, without waiting for it. It removes the report record with its captured message context and profile snapshot, its stored evidence copies, its search index entry, and the reporter's duplicate-report reservation for it. An evidence copy that another report also uses is kept for that report. A pending or a resolved report can be deleted, and the reporter is not told.
|
This is the deletion [report retention](#report-retention) runs at the end of the retention period, without waiting for it. It removes the report record with its captured message context and profile snapshot, its notes, its stored evidence copies, its search index entry, and the reporter's duplicate-report reservation for it. An evidence copy that another report also uses is kept for that report. A pending or a resolved report can be deleted, and the reporter is not told.
|
||||||
|
|
||||||
:::caution[Deletion is permanent]
|
:::caution[Deletion is permanent]
|
||||||
Deleted evidence cannot be restored. A report with a legal hold that has not ended is refused. Clear the hold first when the report should go.
|
Deleted evidence cannot be restored. A report with a legal hold that has not ended is refused. Clear the hold first when the report should go.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {createStringType, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||||
|
import {z} from 'zod';
|
||||||
|
|
||||||
|
export const ReportNoteRequest = z.object({
|
||||||
|
body: createStringType(1, 2000).describe('The internal note. Staff only'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ReportNoteRequest = z.infer<typeof ReportNoteRequest>;
|
||||||
|
|
||||||
|
export const ReportNoteResponse = z.object({
|
||||||
|
note_id: SnowflakeStringType,
|
||||||
|
author_id: SnowflakeStringType,
|
||||||
|
body: z.string(),
|
||||||
|
created_at: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ReportNoteResponse = z.infer<typeof ReportNoteResponse>;
|
||||||
|
|
||||||
|
export const ReportNoteListResponse = z.object({
|
||||||
|
notes: z.array(ReportNoteResponse).describe('The notes on the report, oldest first'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ReportNoteListResponse = z.infer<typeof ReportNoteListResponse>;
|
||||||
@@ -12,6 +12,7 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
|||||||
import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/AdminAclType';
|
import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/AdminAclType';
|
||||||
import {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
|
import {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
|
||||||
import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||||
|
import {ReportNoteResponse} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
|
||||||
import {UserAdminResponseSchema} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
import {UserAdminResponseSchema} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||||
import {CaptchaConfigResponse, CaptchaConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
|
import {CaptchaConfigResponse, CaptchaConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
|
||||||
import {
|
import {
|
||||||
@@ -1672,6 +1673,10 @@ export const ReportAdminResponseSchema = z.object({
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Why the report is held. Returned by the report detail only. Null when the report has no hold.'),
|
.describe('Why the report is held. Returned by the report detail only. Null when the report has no hold.'),
|
||||||
|
notes: z
|
||||||
|
.array(ReportNoteResponse)
|
||||||
|
.optional()
|
||||||
|
.describe('Internal staff notes on the report, oldest first. Returned by the report detail only.'),
|
||||||
reason: z.string().nullable().optional(),
|
reason: z.string().nullable().optional(),
|
||||||
reason_label: z.string().nullable().optional(),
|
reason_label: z.string().nullable().optional(),
|
||||||
reason_highest_priority: z.boolean().nullable().optional(),
|
reason_highest_priority: z.boolean().nullable().optional(),
|
||||||
|
|||||||
@@ -6131,6 +6131,33 @@
|
|||||||
"primary_key": "((guild_id))",
|
"primary_key": "((guild_id))",
|
||||||
"options": ""
|
"options": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "iar_report_notes",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "report_id",
|
||||||
|
"type": "bigint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "note_id",
|
||||||
|
"type": "bigint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "author_id",
|
||||||
|
"type": "bigint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primary_key": "((report_id), note_id)",
|
||||||
|
"options": "CLUSTERING ORDER BY (note_id ASC)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "iar_submissions",
|
"name": "iar_submissions",
|
||||||
"columns": [
|
"columns": [
|
||||||
|
|||||||
Reference in new issue
Block a user