diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 026a3bb61..72defb667 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -11027,14 +11027,15 @@ }, "outcome": { "description": "What happened to the reported content or account. Required for actioned, where no_violation is refused. Optional for no_violation, where it can only be no_violation. Refused for duplicate. The reporter is told it", - "x-enumNames": ["removed", "restricted", "action_taken", "no_violation"], + "x-enumNames": ["removed", "warned", "restricted", "action_taken", "no_violation"], "x-enumDescriptions": [ "The reported content, profile field or community was removed", + "The account responsible for the reported item was warned", "The reported account was restricted and the content was left up", "Action was taken under the rules, and the reporter is not told which", "The reported content or account does not break the rules and was left as it is" ], - "enum": ["removed", "restricted", "action_taken", "no_violation"], + "enum": ["removed", "warned", "restricted", "action_taken", "no_violation"], "type": "string" }, "duplicate_of": { diff --git a/fluxer_admin/src/templates/pages/report_detail.rs b/fluxer_admin/src/templates/pages/report_detail.rs index 79d7333c2..752b70dc8 100644 --- a/fluxer_admin/src/templates/pages/report_detail.rs +++ b/fluxer_admin/src/templates/pages/report_detail.rs @@ -601,13 +601,14 @@ fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup class={(FORM_CONTROL_CLASS) " h-8 appearance-none px-3 py-1.5 pr-10"} { option value="" selected disabled { "Choose one" } option value="removed" { "Content removed" } + option value="warned" { "Warning given" } option value="restricted" { "Account restricted" } option value="action_taken" { "Other action taken" } } (select_chevron()) } p class="text-neutral-500 text-xs" { - "What happened to the reported content or account. The reporter is told it. Other action taken, such as a warning, tells them we acted but not how." + "What happened to the reported content or account. The reporter is told it. Warning given and other action taken tell them we acted but not how." } } fieldset class="flex flex-col gap-3" data-show-for-resolution="duplicate" hidden disabled { diff --git a/fluxer_admin/tests/notification_writes.rs b/fluxer_admin/tests/notification_writes.rs index 40201a81f..09c260061 100644 --- a/fluxer_admin/tests/notification_writes.rs +++ b/fluxer_admin/tests/notification_writes.rs @@ -186,6 +186,8 @@ async fn report_resolve_sends_notify_reporter_from_the_checkbox() { assert_eq!(duplicate.body["notify_reporter"], json!(true)); assert!(duplicate.body.get("outcome").is_none()); assert_eq!(duplicate.body["duplicate_of"], json!("1400000000000000001")); + let warned = submit(&uri, "resolution=actioned&outcome=warned").await; + assert_eq!(warned.body["outcome"], json!("warned")); let other_action = submit(&uri, "resolution=actioned&outcome=action_taken").await; assert_eq!(other_action.body["outcome"], json!("action_taken")); } diff --git a/fluxer_api/src/api/admin/services/ReporterResolutionNotifier.ts b/fluxer_api/src/api/admin/services/ReporterResolutionNotifier.ts index adb2b7cba..c35838fad 100644 --- a/fluxer_api/src/api/admin/services/ReporterResolutionNotifier.ts +++ b/fluxer_api/src/api/admin/services/ReporterResolutionNotifier.ts @@ -33,6 +33,8 @@ interface ReporterNoticeResult { skipped: ReporterNoticeSkipReason | null; } +type ReporterToldOutcome = Exclude; + function skippedNotice(reason: ReporterNoticeSkipReason): ReporterNoticeResult { return {dmSent: false, emailSent: false, dsaEmailSent: false, skipped: reason}; } @@ -59,17 +61,18 @@ export class ReporterResolutionNotifier { outcome: ReportItemOutcome, ): Promise { const comment = publicComment ?? ''; + const told = outcome === 'warned' ? 'action_taken' : outcome; if (report.reporterId) { - return this.notifyAccountReporter(report, report.reporterId, comment, outcome); + return this.notifyAccountReporter(report, report.reporterId, comment, told); } - return this.notifyDsaReporter(report, comment, outcome); + return this.notifyDsaReporter(report, comment, told); } private async notifyAccountReporter( report: IARSubmission, reporterId: UserID, comment: string, - outcome: ReportItemOutcome, + outcome: ReporterToldOutcome, ): Promise { const {users: userRepository, email: emailService} = this.deps.apiContext.services; const reportId = report.reportId.toString(); @@ -115,7 +118,7 @@ export class ReporterResolutionNotifier { private async notifyDsaReporter( report: IARSubmission, comment: string, - outcome: ReportItemOutcome, + outcome: ReporterToldOutcome, ): Promise { const email = report.reporterEmail; if (!email) { @@ -134,7 +137,7 @@ export class ReporterResolutionNotifier { reporter: User, reportId: string, comment: string, - outcome: ReportItemOutcome, + outcome: ReporterToldOutcome, ): Promise { const systemDm = this.deps.systemDm; if (!systemDm) { diff --git a/fluxer_api/src/api/admin/tests/AdminStaffNotifications.test.ts b/fluxer_api/src/api/admin/tests/AdminStaffNotifications.test.ts index 3d17e3446..8e1c6684d 100644 --- a/fluxer_api/src/api/admin/tests/AdminStaffNotifications.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminStaffNotifications.test.ts @@ -333,7 +333,7 @@ describe('Admin staff notifications', () => { expect(dm?.content).toContain('We found that what you reported broke our rules, and we took action.'); expect(dm?.content).not.toMatch(/warn|restricted|suspend/i); const metadata = await auditMetadata('resolve_report', reportId); - expect(metadata?.get('outcome')).toBe('action_taken'); + expect(metadata?.get('outcome')).toBe('warned'); expect(metadata?.get('reporter_dm_sent')).toBe('true'); }); @@ -347,6 +347,18 @@ describe('Admin staff notifications', () => { ]); }); + test('a DSA notice resolved with a warning emails other action taken', async () => { + const {email, reportId} = await fileDsaGuildNotice(); + await clearTestEmails(harness); + await resolve(reportId, {outcome: 'warned'}); + const sent = await listTestEmails(harness, {recipient: email}); + expect(sent.map((entry) => [entry.type, entry.metadata.outcome])).toEqual([ + ['dsa_report_resolved', 'action_taken'], + ]); + const metadata = await auditMetadata('resolve_report', reportId); + expect(metadata?.get('outcome')).toBe('warned'); + }); + test('a DSA notice resolved with notify_reporter false gets the decision email without the comment', async () => { const {email, reportId} = await fileDsaGuildNotice(); await clearTestEmails(harness); diff --git a/fluxer_api/src/api/enforcement/EnforcementDecisionService.ts b/fluxer_api/src/api/enforcement/EnforcementDecisionService.ts index c59f37782..5d817ed8c 100644 --- a/fluxer_api/src/api/enforcement/EnforcementDecisionService.ts +++ b/fluxer_api/src/api/enforcement/EnforcementDecisionService.ts @@ -239,7 +239,7 @@ function reportOutcome( (removeEvidence && evidence.some((message) => message.message_id === messageId)); if (removed) return 'removed'; } - if (kind === 'warning') return 'action_taken'; + if (kind === 'warning') return 'warned'; return RESTRICTING_KINDS.has(kind) ? 'restricted' : null; } diff --git a/fluxer_docs/src/content/docs/admin-api/enforcement.mdx b/fluxer_docs/src/content/docs/admin-api/enforcement.mdx index 959ea5c8a..f00034913 100644 --- a/fluxer_docs/src/content/docs/admin-api/enforcement.mdx +++ b/fluxer_docs/src/content/docs/admin-api/enforcement.mdx @@ -93,7 +93,7 @@ Removed content cannot be restored, so these decisions end only as `reversed`. ### Reporter outcomes -A decision that resolves linked reports tells each reporter what happened to the item. `removed` covers a reported message the decision removed, a profile report met by a profile reset, and a community removal. `restricted` covers a suspension, closure or feature limit. `action_taken` covers a warning and tells the reporter that Fluxer acted on what they reported, without naming the action or saying the account was restricted. A content removal or profile reset that leaves the reported item in place leaves the report pending. A manual resolution through [Update report](/admin-api/reports/#update-report) names the outcome itself, and a duplicate takes the outcome stored on the report it repeats. +A decision that resolves linked reports tells each reporter what happened to the item. `removed` covers a reported message the decision removed, a profile report met by a profile reset, and a community removal. `restricted` covers a suspension, closure or feature limit. `warned` covers a warning and is stored as such, but the reporter is told the same as for `action_taken`: that Fluxer acted on what they reported, without naming the action or saying the account was restricted. A content removal or profile reset that leaves the reported item in place leaves the report pending. A manual resolution through [Update report](/admin-api/reports/#update-report) names the outcome itself, and a duplicate takes the outcome stored on the report it repeats. ### Network blocks diff --git a/fluxer_docs/src/content/docs/admin-api/reports.mdx b/fluxer_docs/src/content/docs/admin-api/reports.mdx index 73e082f55..5424377f4 100644 --- a/fluxer_docs/src/content/docs/admin-api/reports.mdx +++ b/fluxer_docs/src/content/docs/admin-api/reports.mdx @@ -685,7 +685,7 @@ Resolves one report and returns a [report resolution](#report-resolution-object) | public_comment?1 | string | The comment shown to the reporter (0-512 characters) | | notify_reporter?2 | boolean | Whether the notice to the reporter includes `public_comment` (default true) | | resolution3 | string | How the report was resolved, one of `actioned`, `no_violation`, or `duplicate` | -| outcome?4 | string | What happened to the reported item, `removed`, `restricted`, `action_taken`, or `no_violation` | +| outcome?4 | string | What happened to the reported item, `removed`, `warned`, `restricted`, `action_taken`, or `no_violation` | | duplicate_of?5 | snowflake | The report this one repeats | 1 Stored as null when it is omitted or empty. Otherwise it is stored, and the resolution notice shows it to the reporter unless `notify_reporter` is false @@ -694,11 +694,11 @@ Resolves one report and returns a [report resolution](#report-resolution-object) 3 Not stored on the report and not returned by any report route. It is recorded in the audit entry -4 Required for `actioned`, which refuses `no_violation`. Optional for `no_violation`, which sets `no_violation` and refuses any other value. Refused for `duplicate`. A mistake fails with `REPORT_OUTCOME_INVALID`. `removed` means the reported content, profile field or community was removed. `restricted` means the reported account was restricted and the item was left up. `action_taken` means Fluxer acted under its rules in another way, such as a warning, and the reporter is not told how. `no_violation` means the item breaks no rule and was left as it is. The outcome is stored on the report and the reporter is told it +4 Required for `actioned`, which refuses `no_violation`. Optional for `no_violation`, which sets `no_violation` and refuses any other value. Refused for `duplicate`. A mistake fails with `REPORT_OUTCOME_INVALID`. `removed` means the reported content, profile field or community was removed. `warned` means the account responsible was warned, and the reporter is told only that action was taken. `restricted` means the reported account was restricted and the item was left up. `action_taken` means Fluxer acted under its rules in another way, and the reporter is not told how. `no_violation` means the item breaks no rule and was left as it is. The outcome is stored on the report and the reporter is told it 5 Required for `duplicate` and refused otherwise, as is a report naming itself, with `REPORT_OUTCOME_INVALID`. The duplicate takes the outcome stored on that report and the reporter is told it. While that report has no outcome, because it is still pending or was resolved before outcomes were stored, the duplicate is resolved without a notice to its reporter. An unknown report returns 404 `UNKNOWN_REPORT` -The admin panel's resolve form has a required Resolution select with three choices: Action taken (`actioned`), No violation (`no_violation`), and Duplicate (`duplicate`). Action taken also shows a required Outcome select: Content removed (`removed`), Account restricted (`restricted`), and Other action taken (`action_taken`). No violation sends the outcome `no_violation`. Duplicate shows a required Duplicate of field for the report ID. The panel refuses a submission without a resolution, without an outcome for Action taken, or without a report ID for Duplicate, and the report stays pending. After a resolve, the panel confirms the choice in its message, such as "Report resolved: No violation". Staff find the choice in the `resolution` and `outcome` metadata of the `resolve_report` entry in the audit log. +The admin panel's resolve form has a required Resolution select with three choices: Action taken (`actioned`), No violation (`no_violation`), and Duplicate (`duplicate`). Action taken also shows a required Outcome select: Content removed (`removed`), Warning given (`warned`), Account restricted (`restricted`), and Other action taken (`action_taken`). No violation sends the outcome `no_violation`. Duplicate shows a required Duplicate of field for the report ID. The panel refuses a submission without a resolution, without an outcome for Action taken, or without a report ID for Duplicate, and the report stays pending. After a resolve, the panel confirms the choice in its message, such as "Report resolved: No violation". Staff find the choice in the `resolution` and `outcome` metadata of the `resolve_report` entry in the audit log. A [decision](/admin-api/enforcement/) that links pending reports resolves them as `actioned` itself, with the outcome its kind implies, so no separate update is needed. diff --git a/packages/schema/src/domains/admin/AdminSchemas.ts b/packages/schema/src/domains/admin/AdminSchemas.ts index 93ecad433..5a091d5bc 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -249,6 +249,7 @@ const ReportResolutionEnum = createNamedStringLiteralUnion( const ReportItemOutcomeEnum = createNamedStringLiteralUnion( [ ['removed', 'removed', 'The reported content, profile field or community was removed'], + ['warned', 'warned', 'The account responsible for the reported item was warned'], ['restricted', 'restricted', 'The reported account was restricted and the content was left up'], ['action_taken', 'action_taken', 'Action was taken under the rules, and the reporter is not told which'], ['no_violation', 'no_violation', 'The reported content or account does not break the rules and was left as it is'],