Skip to content

Commit 1b295d1

Browse files
authored
implement Ability for moderator to report users as spam (#1076)
- Ticket: https://openscience.atlassian.net/browse/ENG-10679 - Feature flag: n/a ## Purpose Recently users have started started creating “fake” accounts with ORCIDs in order to submit AI generated content to preprint providers. This is “spam” that our providers are then required to report the users (by email) to OSF support staff. A simpler solution would be to have a report spam functionality in the moderator dashboard. This would generate an email notification to [support@cos.io](mailto:support@cos.io) regarding the preprint in question, and would allow support team members to quickly go into admin and disable to creator account. Note: this will be for both preprints and registries.
1 parent d0a839b commit 1b295d1

10 files changed

Lines changed: 122 additions & 9 deletions

File tree

‎src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,13 @@ <h4 class="line-height-3">{{ 'preprints.details.decision.decline.label' | transl
9797

9898
<section class="w-full mt-4 flex flex-column gap-2">
9999
<div class="flex flex-row align-items-start gap-2">
100-
<p-radio-button inputId="accept" class="pt-1" [value]="ReviewsState.Accepted" [(ngModel)]="decision" />
100+
<p-radio-button
101+
inputId="accept"
102+
class="pt-1"
103+
[value]="ReviewsState.Accepted"
104+
[(ngModel)]="decision"
105+
(change)="decisionToggled()"
106+
/>
101107

102108
<label for="accept" class="cursor-pointer">
103109
<h4 class="line-height-3">{{ 'preprints.details.decision.accept.label' | translate }}</h4>
@@ -106,13 +112,32 @@ <h4 class="line-height-3">{{ 'preprints.details.decision.accept.label' | transla
106112
</div>
107113

108114
<div class="flex flex-row align-items-start gap-2">
109-
<p-radio-button inputId="reject" class="pt-1" [value]="rejectRadioButtonValue()" [(ngModel)]="decision" />
115+
<p-radio-button
116+
inputId="reject"
117+
class="pt-1"
118+
[value]="rejectRadioButtonValue()"
119+
[(ngModel)]="decision"
120+
(change)="decisionToggled()"
121+
/>
110122

111123
<label for="reject" class="cursor-pointer">
112124
<h4 class="line-height-3">{{ rejectOptionLabel() | translate }}</h4>
113125
<p class="line-height-3">{{ rejectOptionExplanation() | translate }}</p>
114126
</label>
115127
</div>
128+
129+
<div class="flex flex-row align-items-start gap-2">
130+
<p-radio-button
131+
inputId="reportSpam"
132+
class="pt-1"
133+
[value]="ReviewActionTrigger.ReportSpam"
134+
[(ngModel)]="decision"
135+
(change)="decisionToggled()"
136+
/>
137+
<label for="reportSpam" class="cursor-pointer">
138+
<h4 class="line-height-3">{{ 'preprints.details.decision.reportSpam.label' | translate }}</h4>
139+
</label>
140+
</div>
116141
</section>
117142

118143
<section class="w-full mt-4">

‎src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
SubmitRequestsDecision,
1414
SubmitReviewsDecision,
1515
} from '@osf/features/preprints/store/preprint';
16+
import { ToastService } from '@osf/shared/services/toast.service';
1617

1718
import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock';
1819
import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details';
@@ -21,6 +22,7 @@ import { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.mock';
2122
import { provideOSFCore } from '@testing/osf.testing.provider';
2223
import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
2324
import { provideMockStore } from '@testing/providers/store-provider.mock';
25+
import { ToastServiceMock } from '@testing/providers/toast-provider.mock';
2426

2527
import { PreprintMakeDecisionComponent } from './preprint-make-decision.component';
2628

@@ -44,6 +46,7 @@ describe('PreprintMakeDecisionComponent', () => {
4446
providers: [
4547
provideOSFCore(),
4648
MockProvider(Router, routerMock),
49+
MockProvider(ToastService, ToastServiceMock.simple()),
4750
provideMockStore({
4851
signals: [{ selector: PreprintSelectors.getPreprint, value: mockPreprint }],
4952
}),

‎src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import {
2626
SubmitReviewsDecision,
2727
} from '@osf/features/preprints/store/preprint';
2828
import { InputLimits } from '@osf/shared/constants/input-limits.const';
29+
import { ReviewActionTrigger } from '@osf/shared/enums/trigger-action.enum';
2930
import { StringOrNull } from '@osf/shared/helpers/types.helper';
31+
import { ToastService } from '@osf/shared/services/toast.service';
3032

3133
@Component({
3234
selector: 'osf-preprint-make-decision',
@@ -38,6 +40,7 @@ import { StringOrNull } from '@osf/shared/helpers/types.helper';
3840
export class PreprintMakeDecisionComponent {
3941
private readonly translateService = inject(TranslateService);
4042
private readonly router = inject(Router);
43+
private readonly toastService = inject(ToastService);
4144

4245
private readonly actions = createDispatchMap({
4346
submitReviewsDecision: SubmitReviewsDecision,
@@ -53,14 +56,15 @@ export class PreprintMakeDecisionComponent {
5356

5457
dialogVisible = false;
5558
didValidate = signal<boolean>(false);
56-
decision = signal<ReviewsState>(ReviewsState.Accepted);
59+
decision = signal<ReviewsState | ReviewActionTrigger.ReportSpam>(ReviewsState.Accepted);
5760
initialReviewerComment = signal<StringOrNull>(null);
5861
reviewerComment = signal<StringOrNull>(null);
5962
requestDecisionJustification = signal<StringOrNull>(null);
6063
saving = signal<boolean>(false);
6164

6265
readonly decisionCommentLimit = InputLimits.decisionComment.maxLength;
6366
readonly ReviewsState = ReviewsState;
67+
readonly ReviewActionTrigger = ReviewActionTrigger;
6468

6569
labelDecisionButton = computed(() => {
6670
const preprint = this.preprint();
@@ -99,7 +103,11 @@ export class PreprintMakeDecisionComponent {
99103
const preprint = this.preprint();
100104
const reviewsState = preprint?.reviewsState;
101105

102-
if (this.isPendingWithdrawal() || reviewsState === ReviewsState.Pending) {
106+
if (
107+
this.decision() === ReviewActionTrigger.ReportSpam ||
108+
this.isPendingWithdrawal() ||
109+
reviewsState === ReviewsState.Pending
110+
) {
103111
return 'preprints.details.decision.submitButton.submitDecision';
104112
}
105113

@@ -231,6 +239,11 @@ export class PreprintMakeDecisionComponent {
231239
const preprint = this.preprint();
232240
if (!preprint) return;
233241

242+
if (this.decision() === ReviewActionTrigger.ReportSpam) {
243+
this.reportSpam();
244+
return;
245+
}
246+
234247
let trigger;
235248
if (preprint.reviewsState !== ReviewsState.Pending && this.commentEdited() && !this.decisionChanged()) {
236249
// If the submission is not pending,
@@ -306,6 +319,32 @@ export class PreprintMakeDecisionComponent {
306319
}
307320
}
308321

322+
decisionToggled() {
323+
if (this.decision() === ReviewActionTrigger.ReportSpam) {
324+
this.reviewerComment.set(null);
325+
} else {
326+
this.reviewerComment.set(this.initialReviewerComment());
327+
}
328+
}
329+
330+
private reportSpam() {
331+
const preprint = this.preprint();
332+
if (!preprint) return;
333+
334+
this.saving.set(true);
335+
this.actions
336+
.submitReviewsDecision(ReviewActionTrigger.ReportSpam, this.reviewerComment()?.trim() || null)
337+
.pipe(finalize(() => this.saving.set(false)))
338+
.subscribe({
339+
next: () => {
340+
this.toastService.showSuccess('preprints.details.decision.reportSpam.success');
341+
this.dialogVisible = false;
342+
this.decision.set(preprint.reviewsState);
343+
this.reviewerComment.set(this.initialReviewerComment());
344+
},
345+
});
346+
}
347+
309348
requestDecisionToggled() {
310349
if (!this.isPendingWithdrawal()) {
311350
return;

‎src/app/features/preprints/pages/preprint-details/preprint-details.component.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { PrerenderReadyService } from '@core/services/prerender-ready.service';
3030
import { ClearCurrentProvider } from '@core/store/provider';
3131
import { UserSelectors } from '@core/store/user';
3232
import { ReviewPermissions } from '@osf/shared/enums/review-permissions.enum';
33+
import { ReviewActionTrigger } from '@osf/shared/enums/trigger-action.enum';
3334
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
3435
import { DataciteService } from '@osf/shared/services/datacite/datacite.service';
3536
import { LoaderService } from '@osf/shared/services/loader.service';
@@ -147,7 +148,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy {
147148
});
148149

149150
latestAction = computed(() => {
150-
const actions = this.reviewActions();
151+
// banners and the decision dialog describe the last decision, and a spam report is not one
152+
const actions = this.reviewActions().filter((action) => action.trigger !== ReviewActionTrigger.ReportSpam);
151153
return actions.length > 0 ? actions[0] : null;
152154
});
153155

‎src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<div class="flex flex-column gap-4">
2-
@for (action of reviewActions(); track $index) {
2+
@for (action of decisionActions(); track $index) {
33
<p class="flex flex-wrap gap-1">
44
@switch (action.toState) {
55
@case (SubmissionReviewStatus.Pending) {
@@ -63,6 +63,17 @@
6363
}
6464
</label>
6565
</div>
66+
67+
<div class="flex gap-2">
68+
<p-radioButton
69+
[formControlName]="ModerationDecisionFormControls.Action"
70+
[inputId]="ReviewActionTrigger.ReportSpam"
71+
[value]="ReviewActionTrigger.ReportSpam"
72+
></p-radioButton>
73+
<label [for]="ReviewActionTrigger.ReportSpam" class="m-0 flex gap-2 flex-column">
74+
<p class="font-bold">{{ 'moderation.makeDecision.reportSpam' | translate }}</p>
75+
</label>
76+
</div>
6677
</div>
6778

6879
<div class="flex flex-column gap-1">
@@ -92,6 +103,17 @@
92103
<p>{{ 'moderation.makeDecision.forceWithdrawMessage' | translate }}</p>
93104
</label>
94105
</div>
106+
107+
<div class="flex gap-2">
108+
<p-radioButton
109+
[formControlName]="ModerationDecisionFormControls.Action"
110+
[inputId]="ReviewActionTrigger.ReportSpam"
111+
[value]="ReviewActionTrigger.ReportSpam"
112+
></p-radioButton>
113+
<label [for]="ReviewActionTrigger.ReportSpam" class="m-0 flex gap-2 flex-column">
114+
<p class="font-bold">{{ 'moderation.makeDecision.reportSpam' | translate }}</p>
115+
</label>
116+
</div>
95117
</div>
96118

97119
<div class="flex flex-column gap-1">

‎src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-
1313
import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum';
1414
import { ReviewActionTrigger, SchemaResponseActionTrigger } from '@osf/shared/enums/trigger-action.enum';
1515
import { DateAgoPipe } from '@osf/shared/pipes/date-ago.pipe';
16+
import { ToastService } from '@osf/shared/services/toast.service';
1617

1718
import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock';
1819
import { provideOSFCore } from '@testing/osf.testing.provider';
1920
import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock';
2021
import { provideMockStore } from '@testing/providers/store-provider.mock';
22+
import { ToastServiceMock } from '@testing/providers/toast-provider.mock';
2123

2224
import { RegistrySelectors } from '../../store/registry';
2325

@@ -43,6 +45,7 @@ function setup(overrides: SetupOverrides = {}) {
4345
providers: [
4446
provideOSFCore(),
4547
provideDynamicDialogRefMock(),
48+
MockProvider(ToastService, ToastServiceMock.simple()),
4649
MockProvider(DynamicDialogConfig, { data: { registry, revisionId } }),
4750
provideMockStore({
4851
signals: [

‎src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { RadioButton } from 'primeng/radiobutton';
99
import { Textarea } from 'primeng/textarea';
1010

1111
import { DatePipe } from '@angular/common';
12-
import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core';
12+
import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core';
1313
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
1414
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
1515

@@ -45,6 +45,9 @@ export class RegistryMakeDecisionComponent {
4545
readonly decisionCommentLimit = InputLimits.decisionComment.maxLength;
4646

4747
readonly reviewActions = select(RegistrySelectors.getReviewActions);
48+
readonly decisionActions = computed(() =>
49+
this.reviewActions().filter((action) => action.trigger !== ReviewActionTrigger.ReportSpam)
50+
);
4851
readonly isSubmitting = select(RegistrySelectors.isReviewActionSubmitting);
4952

5053
private readonly actions = createDispatchMap({ submitDecision: SubmitDecision });
@@ -94,12 +97,14 @@ export class RegistryMakeDecisionComponent {
9497
}
9598

9699
handleSubmission(): void {
97-
const revisionId = this.config.data.revisionId;
100+
const action = this.requestForm.value[ModerationDecisionFormControls.Action] ?? '';
101+
// a spam report is related to the registration and its submitter, so a revision is never its target
102+
const revisionId = action === ReviewActionTrigger.ReportSpam ? null : this.config.data.revisionId;
98103
this.actions
99104
.submitDecision(
100105
{
101106
targetId: revisionId ? revisionId : this.registry.id,
102-
action: this.requestForm.value[ModerationDecisionFormControls.Action] ?? '',
107+
action,
103108
comment: this.requestForm.value[ModerationDecisionFormControls.Comment] ?? '',
104109
},
105110
!!revisionId

‎src/app/features/registry/pages/registry-overview/registry-overview.component.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-l
2929
import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum';
3030
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
3131
import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum';
32+
import { ReviewActionTrigger } from '@osf/shared/enums/trigger-action.enum';
3233
import { toCamelCase } from '@osf/shared/helpers/camel-case';
3334
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
3435
import { LoaderService } from '@osf/shared/services/loader.service';
@@ -232,6 +233,12 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy {
232233
this.toastService.showSuccess(`moderation.makeDecision.${action}Success`);
233234
}
234235

236+
// a spam report leaves the registration untouched, and the reload below drops the
237+
// moderator query param, which would hide the 'Make decision' button
238+
if (data.action === ReviewActionTrigger.ReportSpam) {
239+
return;
240+
}
241+
235242
const currentUrl = this.router.url.split('?')[0];
236243
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
237244
this.router.navigateByUrl(currentUrl);

‎src/app/shared/enums/trigger-action.enum.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export enum ReviewActionTrigger {
77
AcceptWithdrawal = 'accept_withdrawal',
88
RejectWithdrawal = 'reject_withdrawal',
99
RequestEmbargoTermination = 'request_embargo_termination',
10+
ReportSpam = 'report_spam',
1011
}
1112

1213
export enum SchemaResponseActionTrigger {

‎src/assets/i18n/en.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,8 @@
977977
"rejectWithdrawalMessage": "Submission will remain fully public",
978978
"remarksPlaceholder": "Add remarks to registration admins",
979979
"removeSuccess": "Submission has been withdrawn successfully",
980+
"reportSpam": "Report spam",
981+
"reportSpamSuccess": "Submission has been reported as spam",
980982
"submitDecision": "Submit decision",
981983
"withdrawRequest": "Withdraw Request",
982984
"withdrawRequestMessage": "Submission will be withdrawn from the collection and will no longer appear in search results."
@@ -1332,6 +1334,10 @@
13321334
"post": "Submission will be removed from search results and made private.",
13331335
"pre": "Submission will not appear in search results and will remain private."
13341336
},
1337+
"reportSpam": {
1338+
"label": "Report spam",
1339+
"success": "Submission has been reported as spam"
1340+
},
13351341
"settings": {
13361342
"comments": {
13371343
"private": "Comments are not visible to contributors",

0 commit comments

Comments
 (0)