mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-15 05:37:22 +01:00
Reporting challenges (#14756)
* initial commit * update logic to display flagged challenges properly to users and admins * add report button to pages 'My Challenges' and 'Discover Challenges' * allow mods to view flagged messages on challengeDetail view * update showing flagged challenges for group challenges * update showing flagged challenges for a specific challenge * disallow closing a flagged challenge * update notes to reflect apiParams properly * fix css spacing * update challenge en locales * fix spacing * update title of closeChallengeModal * let user know flagged challenges cannot be cloned * fix linting errors * ensure flagged challenges cannot be declared with a winner and cloned via API * define a non user challenge properly * fix logic to check for a nonParticipant and nonLeader user when grabbing flagged challenges * fix linting of max character of 100 / line * remove reporting on 'my challenges' and 'discover challenges' * WIP(challenges): disable clone button and add notes to new functions * WIP(challenges): smol changes * WIP(challenges): clone button only disabled for admin and flagged user; other users can still clone but the flag goes along with the clone * WIP(challenges): stop flags carrying over on cloned challenges * WIP(challenges): typo fixing, undoing a smol change * fix(challenges): improved query logic for flags * WIP(challenges): more smol changes * fix(challenges): refactor queries * fix(challenges): correct My Challenges tab logic * WIP(challenges): fix clone button state * WIP(challenges): really fixed clone button & clear flags from clones * WIP(challenge): implement new design for reporting modal * WIP(challenge): making things pretty * WIP(challenge): conquering the close button * WIP(challenge): fixin some spacing * WIP(challenge): smol fix * WIP(challenge): making sure the button is actually disabled * WIP(challenge): fix blockquote css * fix(tests): no private guilds * fix(lint): curlies etc * fix(test): moderator permission * fix(lint): sure man whatever * fix(lint): bad vim no tabby * fix(test): permissions not contrib lol * fix(challenges): add icon and fix leaky CSS * fix(challenge): correct clone button behavior --------- Co-authored-by: Julius Jung <me@matchajune.io> Co-authored-by: SabreCat <sabe@habitica.com> Co-authored-by: Sabe Jones <sabrecat@gmail.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import {
|
||||
generateChallenge,
|
||||
createAndPopulateGroup,
|
||||
translate as t,
|
||||
} from '../../../../helpers/api-integration/v3';
|
||||
|
||||
describe('POST /challenges/:challengeId/flag', () => {
|
||||
let user;
|
||||
let challenge;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { group, groupLeader } = await createAndPopulateGroup({
|
||||
groupDetails: {
|
||||
name: 'TestParty',
|
||||
type: 'party',
|
||||
privacy: 'private',
|
||||
},
|
||||
members: 1,
|
||||
});
|
||||
|
||||
user = groupLeader;
|
||||
|
||||
challenge = await generateChallenge(user, group);
|
||||
});
|
||||
|
||||
it('returns an error when challenge is not found', async () => {
|
||||
await expect(user.post(`/challenges/${generateUUID()}/flag`))
|
||||
.to.eventually.be.rejected.and.eql({
|
||||
code: 404,
|
||||
error: 'NotFound',
|
||||
message: t('challengeNotFound'),
|
||||
});
|
||||
});
|
||||
|
||||
it('flags a challenge', async () => {
|
||||
const flagResult = await user.post(`/challenges/${challenge._id}/flag`);
|
||||
|
||||
expect(flagResult.challenge.flags[user._id]).to.equal(true);
|
||||
expect(flagResult.challenge.flagCount).to.equal(1);
|
||||
});
|
||||
|
||||
it('flags a challenge with a higher count when from an admin', async () => {
|
||||
await user.update({ 'contributor.admin': true });
|
||||
|
||||
const flagResult = await user.post(`/challenges/${challenge._id}/flag`);
|
||||
|
||||
expect(flagResult.challenge.flags[user._id]).to.equal(true);
|
||||
expect(flagResult.challenge.flagCount).to.equal(5);
|
||||
});
|
||||
|
||||
it('returns an error when user tries to flag a challenge that is already flagged', async () => {
|
||||
await user.post(`/challenges/${challenge._id}/flag`);
|
||||
|
||||
await expect(user.post(`/challenges/${challenge._id}/flag`))
|
||||
.to.eventually.be.rejected.and.eql({
|
||||
code: 404,
|
||||
error: 'NotFound',
|
||||
message: t('messageChallengeFlagAlreadyReported'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import {
|
||||
generateChallenge,
|
||||
createAndPopulateGroup,
|
||||
translate as t,
|
||||
} from '../../../../helpers/api-integration/v3';
|
||||
|
||||
describe('POST /challenges/:challengeId/clearflags', () => {
|
||||
let admin;
|
||||
let nonAdmin;
|
||||
let challenge;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { group, groupLeader, members } = await createAndPopulateGroup({
|
||||
groupDetails: {
|
||||
name: 'TestParty',
|
||||
type: 'party',
|
||||
privacy: 'private',
|
||||
},
|
||||
members: 1,
|
||||
});
|
||||
|
||||
admin = groupLeader;
|
||||
[nonAdmin] = members;
|
||||
|
||||
await admin.update({ 'permissions.moderator': true });
|
||||
|
||||
challenge = await generateChallenge(admin, group);
|
||||
await admin.post(`/challenges/${challenge._id}/flag`);
|
||||
});
|
||||
|
||||
it('returns error when non-admin attempts to clear flags', async () => {
|
||||
await expect(nonAdmin.post(`/challenges/${generateUUID()}/clearflags`))
|
||||
.to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('messageGroupChatAdminClearFlagCount'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when challenge is not found', async () => {
|
||||
await expect(admin.post(`/challenges/${generateUUID()}/clearflags`))
|
||||
.to.eventually.be.rejected.and.eql({
|
||||
code: 404,
|
||||
error: 'NotFound',
|
||||
message: t('challengeNotFound'),
|
||||
});
|
||||
});
|
||||
|
||||
it('clears flags and sets mod flag to false', async () => {
|
||||
await nonAdmin.post(`/challenges/${challenge._id}/flag`);
|
||||
const flagResult = await admin.post(`/challenges/${challenge._id}/clearflags`);
|
||||
|
||||
expect(flagResult.challenge.flagCount).to.eql(0);
|
||||
expect(flagResult.challenge.flags).to.have.property(admin._id, false);
|
||||
expect(flagResult.challenge.flags).to.have.property(nonAdmin._id, true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="row">
|
||||
<report-challenge-modal />
|
||||
<challenge-modal @updatedChallenge="updatedChallenge" />
|
||||
<leave-challenge-modal
|
||||
:challenge-id="challenge._id"
|
||||
@@ -9,11 +10,27 @@
|
||||
:members="members"
|
||||
:challenge-id="challenge._id"
|
||||
:prize="challenge.prize"
|
||||
:flag-count="challenge.flagCount"
|
||||
/>
|
||||
<challenge-member-progress-modal :challenge-id="challenge._id" />
|
||||
<div class="col-12 col-md-8 standard-page">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6">
|
||||
<div
|
||||
v-if="canViewFlags"
|
||||
class="flagged"
|
||||
>
|
||||
<div
|
||||
v-if="flaggedNotHidden"
|
||||
>
|
||||
{{ $t("flaggedNotHidden") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="flaggedAndHidden"
|
||||
>
|
||||
{{ $t("flaggedAndHidden") }}
|
||||
</div>
|
||||
</div>
|
||||
<h1 v-markdown="challenge.name"></h1>
|
||||
<div>
|
||||
<span class="mr-1 ml-0 d-block">
|
||||
@@ -41,7 +58,7 @@
|
||||
createdBy string (helps with RTL languages)-->
|
||||
<!-- @TODO: Implement in V2 strong.margin-left
|
||||
(v-once).svg-icon.calendar-icon(v-html="icons.calendarIcon")
|
||||
| {{$t('endDate')}}
|
||||
{{$t('endDate')}}
|
||||
// "endDate": "End Date: <% endDate %>",-->
|
||||
<!-- span {{challenge.endDate}}-->
|
||||
</div>
|
||||
@@ -169,13 +186,16 @@
|
||||
v-if="isLeader || isAdmin"
|
||||
class="button-container"
|
||||
>
|
||||
<button
|
||||
v-once
|
||||
class="btn btn-primary"
|
||||
@click="cloneChallenge()"
|
||||
>
|
||||
{{ $t('clone') }}
|
||||
</button>
|
||||
<div>
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="flaggedAndHidden"
|
||||
:class="flaggedAndHidden ? 'disabled btn-disabled' : 'btn-primary'"
|
||||
@click="cloneChallenge()"
|
||||
>
|
||||
{{ $t('clone') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLeader || isAdmin"
|
||||
@@ -201,6 +221,17 @@
|
||||
{{ $t('endChallenge') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="button-container"
|
||||
>
|
||||
<button
|
||||
v-once
|
||||
class="btn btn-danger"
|
||||
@click="reportChallenge()"
|
||||
>
|
||||
{{ $t('report') }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<sidebar-section :title="$t('challengeSummary')">
|
||||
<p v-markdown="challenge.summary"></p>
|
||||
@@ -249,6 +280,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
background-color: $gray-700;
|
||||
color: $gray-50;
|
||||
box-shadow: none;
|
||||
cursor: arrow;
|
||||
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-icon {
|
||||
width: 12px;
|
||||
display: inline-block;
|
||||
@@ -312,6 +354,15 @@
|
||||
margin-right: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
.flagged {
|
||||
margin-left: 0em;
|
||||
color: $red-10;
|
||||
|
||||
span {
|
||||
margin-left: 0em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -332,6 +383,7 @@ import challengeModal from './challengeModal';
|
||||
import challengeMemberProgressModal from './challengeMemberProgressModal';
|
||||
import challengeMemberSearchMixin from '@/mixins/challengeMemberSearch';
|
||||
import leaveChallengeModal from './leaveChallengeModal';
|
||||
import reportChallengeModal from './reportChallengeModal';
|
||||
import sidebarSection from '../sidebarSection';
|
||||
import userLink from '../userLink';
|
||||
import groupLink from '../groupLink';
|
||||
@@ -350,6 +402,7 @@ export default {
|
||||
components: {
|
||||
closeChallengeModal,
|
||||
leaveChallengeModal,
|
||||
reportChallengeModal,
|
||||
challengeModal,
|
||||
challengeMemberProgressModal,
|
||||
memberSearchDropdown,
|
||||
@@ -401,6 +454,20 @@ export default {
|
||||
canJoin () {
|
||||
return !this.isMember;
|
||||
},
|
||||
// canViewFlags should allow only moderators/admins to see flags
|
||||
canViewFlags () {
|
||||
const isModerator = this.hasPermission(this.user, 'moderator');
|
||||
if (isModerator && this.challenge.flagCount > 0) return true;
|
||||
return false;
|
||||
},
|
||||
// flaggedNotHidden should allow mods/admins & challenge owner to see flags
|
||||
flaggedNotHidden () {
|
||||
return this.challenge.flagCount === 1;
|
||||
},
|
||||
// flaggedAndHidden should only allow admin to see challenge & flags
|
||||
flaggedAndHidden () {
|
||||
return this.challenge.flagCount > 1;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'challenge.name': {
|
||||
@@ -589,6 +656,14 @@ export default {
|
||||
challenge: this.challenge,
|
||||
});
|
||||
},
|
||||
reportChallenge () {
|
||||
this.$root.$emit('habitica::report-challenge', {
|
||||
challenge: this.challenge,
|
||||
});
|
||||
},
|
||||
async showCannotCloneModal () {
|
||||
this.$root.$emit('bv::show::modal', 'cannot-clone-modal');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -366,7 +366,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<b-modal
|
||||
id="close-challenge-modal"
|
||||
:title="$t('createGuild')"
|
||||
:title="$t('endChallenge')"
|
||||
size="md"
|
||||
>
|
||||
<div
|
||||
@@ -17,31 +17,42 @@
|
||||
</h2>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<div class="col-12">
|
||||
<div class="support-habitica">
|
||||
<!-- @TODO: Add challenge achievement badge here-->
|
||||
<span
|
||||
v-if="isFlagged"
|
||||
class="col-12"
|
||||
>
|
||||
<div>{{ $t('cannotClose') }}</div>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="col-12"
|
||||
>
|
||||
<div class="col-12">
|
||||
<div class="support-habitica">
|
||||
<!-- @TODO: Add challenge achievement badge here-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<strong v-once>{{ $t('selectChallengeWinnersDescription') }}</strong>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<member-search-dropdown
|
||||
:text="winnerText"
|
||||
:members="members"
|
||||
:challenge-id="challengeId"
|
||||
@member-selected="selectMember"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button
|
||||
v-once
|
||||
class="btn btn-primary"
|
||||
@click="closeChallenge"
|
||||
>
|
||||
{{ $t('awardWinners') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<strong v-once>{{ $t('selectChallengeWinnersDescription') }}</strong>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<member-search-dropdown
|
||||
:text="winnerText"
|
||||
:members="members"
|
||||
:challenge-id="challengeId"
|
||||
@member-selected="selectMember"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button
|
||||
v-once
|
||||
class="btn btn-primary"
|
||||
@click="closeChallenge"
|
||||
>
|
||||
{{ $t('awardWinners') }}
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
<div class="col-12">
|
||||
<hr>
|
||||
<div class="or">
|
||||
@@ -123,7 +134,7 @@ export default {
|
||||
components: {
|
||||
memberSearchDropdown,
|
||||
},
|
||||
props: ['challengeId', 'members', 'prize'],
|
||||
props: ['challengeId', 'members', 'prize', 'flagCount'],
|
||||
data () {
|
||||
return {
|
||||
winner: {},
|
||||
@@ -134,6 +145,9 @@ export default {
|
||||
if (!this.winner.profile) return this.$t('selectMember');
|
||||
return this.winner.profile.name;
|
||||
},
|
||||
isFlagged () {
|
||||
return this.flagCount > 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectMember (member) {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<b-modal
|
||||
id="report-challenge"
|
||||
size="md"
|
||||
:hide-footer="true"
|
||||
:hide-header="true"
|
||||
>
|
||||
<div class="modal-body">
|
||||
<div class="heading">
|
||||
<h5
|
||||
v-html="$t('abuseFlagModalHeading')"
|
||||
>
|
||||
</h5>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="svg-icon close-icon icon-16 color"
|
||||
aria-hidden="true"
|
||||
tabindex="0"
|
||||
@click="close()"
|
||||
@keypress.enter="close()"
|
||||
v-html="icons.close"
|
||||
></span>
|
||||
</div>
|
||||
<blockquote>
|
||||
<div
|
||||
v-html="abuseObject.name"
|
||||
>
|
||||
</div>
|
||||
</blockquote>
|
||||
<div>
|
||||
<span class="why-report">{{ $t('whyReportingChallenge') }}</span>
|
||||
<textarea
|
||||
v-model="reportComment"
|
||||
class="form-control"
|
||||
:placeholder="$t('whyReportingChallengePlaceholder')"
|
||||
></textarea>
|
||||
</div>
|
||||
<p
|
||||
class="report-guidelines"
|
||||
v-html="$t('abuseFlagModalBodyChallenge', abuseFlagModalBody)"
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="buttons text-center">
|
||||
<div class="button-spacing">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
:class="{disabled: !reportComment}"
|
||||
@click="reportAbuse()"
|
||||
>
|
||||
{{ $t('report') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="button-spacing">
|
||||
<a
|
||||
class="cancel-link"
|
||||
@click.prevent="close()"
|
||||
>
|
||||
{{ $t('cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasPermission(user, 'moderator')"
|
||||
class="reset-flag-count d-flex justify-content-center align-items-middle"
|
||||
@click="clearFlagCount()"
|
||||
>
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon icon-16 color ml-auto mr-2 my-auto"
|
||||
v-html="icons.report"
|
||||
></div>
|
||||
<div
|
||||
class="mr-auto my-auto"
|
||||
@click="clearFlagCount()"
|
||||
>
|
||||
{{ $t('resetFlags') }}
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#report-challenge {
|
||||
h5 {
|
||||
color: #F23035;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border: none;
|
||||
padding-bottom: 0px;
|
||||
padding-top: 12px;
|
||||
height: 16px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.modal-body {
|
||||
padding: 0px 8px 0px 8px;
|
||||
}
|
||||
|
||||
span.svg-icon.close-icon.icon-16 {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-top: -32px;
|
||||
margin-right: -16px;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
color: $gray-300;
|
||||
stroke-width: 0px;
|
||||
|
||||
&:hover {
|
||||
color: $gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
.heading h5 {
|
||||
margin-bottom: 24px;
|
||||
margin-top: 16px;
|
||||
color: $red-10;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.why-report {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
line-height: 1.71;
|
||||
color: $gray-50;
|
||||
}
|
||||
|
||||
.report-guidelines {
|
||||
line-height: 1.71;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-radius: 4px;
|
||||
background-color: $gray-700;
|
||||
padding: 8px 16px 8px 16px;
|
||||
margin-top: 24px;
|
||||
font-weight: bold;
|
||||
color: $gray-10;
|
||||
height: max-conent;;
|
||||
}
|
||||
|
||||
textarea {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
border: solid 1px $gray-400;
|
||||
height: 64px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
padding: 0 16px 0 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.button-spacing {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn-danger.disabled {
|
||||
background-color: $white;
|
||||
color: $gray-50;
|
||||
line-height: 1.71;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
a.cancel-link {
|
||||
color: $purple-300;
|
||||
}
|
||||
|
||||
.reset-flag-count {
|
||||
margin: 16px -16px -16px -16px;
|
||||
height: 48px;
|
||||
color: $maroon-50;
|
||||
background-color: rgba(255, 182, 184, 0.25);
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapState } from '@/libs/store';
|
||||
import notifications from '@/mixins/notifications';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import svgClose from '@/assets/svg/close.svg';
|
||||
import svgReport from '@/assets/svg/report.svg';
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [notifications, userStateMixin],
|
||||
data () {
|
||||
const abuseFlagModalBody = {
|
||||
firstLinkStart: '<a href="/static/community-guidelines" target="_blank">',
|
||||
secondLinkStart: '<a href="/static/terms" target="_blank">',
|
||||
linkEnd: '</a>',
|
||||
};
|
||||
|
||||
return {
|
||||
abuseFlagModalBody,
|
||||
abuseObject: '',
|
||||
groupId: '',
|
||||
reportComment: '',
|
||||
icons: Object.freeze({
|
||||
close: svgClose,
|
||||
report: svgReport,
|
||||
}),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
created () {
|
||||
this.$root.$on('habitica::report-challenge', this.handleReport);
|
||||
},
|
||||
destroyed () {
|
||||
this.$root.$off('habitica::report-challenge', this.handleReport);
|
||||
},
|
||||
methods: {
|
||||
close () {
|
||||
this.$root.$emit('bv::hide::modal', 'report-challenge');
|
||||
},
|
||||
async reportAbuse () {
|
||||
if (!this.reportComment) return;
|
||||
this.$store.dispatch('challenges:flag', {
|
||||
challengeId: this.abuseObject.id,
|
||||
comment: this.reportComment,
|
||||
}).then(() => {
|
||||
this.text(this.$t('abuseReported'));
|
||||
this.close();
|
||||
}).catch(() => {});
|
||||
},
|
||||
async clearFlagCount () {
|
||||
await this.$store.dispatch('challenges:clearFlagCount', {
|
||||
challengeId: this.abuseObject.id,
|
||||
});
|
||||
this.close();
|
||||
},
|
||||
handleReport (data) {
|
||||
if (!data.challenge) return;
|
||||
this.abuseObject = data.challenge;
|
||||
this.reportComment = '';
|
||||
this.$root.$emit('bv::show::modal', 'report-challenge');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -97,3 +97,15 @@ export async function selectChallengeWinner (store, payload) {
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function flag (store, payload) {
|
||||
const response = await axios.post(`/api/v3/challenges/${payload.challengeId}/flag`, {
|
||||
comment: payload.comment,
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function clearFlagCount (store, payload) {
|
||||
const response = await axios.post(`/api/v3/challenges/${payload.challengeId}/clearflags`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
@@ -88,13 +88,23 @@
|
||||
"summaryRequired": "Summary is required",
|
||||
"summaryTooLong": "Summary is too long",
|
||||
"descriptionRequired": "Description is required",
|
||||
"locationRequired": "Location of challenge is required ('Add to')",
|
||||
"locationRequired": "Location of Challenge is required ('Add to')",
|
||||
"categoiresRequired": "One or more categories must be selected",
|
||||
"viewProgressOf": "View Progress Of",
|
||||
"viewProgress": "View Progress",
|
||||
"selectMember": "Select Member",
|
||||
"confirmKeepChallengeTasks": "Do you want to keep challenge tasks?",
|
||||
"confirmKeepChallengeTasks": "Do you want to keep Challenge tasks?",
|
||||
"selectParticipant": "Select a Participant",
|
||||
"yourReward": "Your Reward",
|
||||
"wonChallengeDesc": "<%= challengeName %> selected you as the winner! Your win has been recorded in your Achievements."
|
||||
"wonChallengeDesc": "<%= challengeName %> selected you as the winner! Your win has been recorded in your Achievements.",
|
||||
"messageChallengeFlagAlreadyReported": "You have already reported this Challenge.",
|
||||
"flaggedNotHidden": "Challenge flagged once, not hidden",
|
||||
"flaggedAndHidden": "Challenge flagged and hidden",
|
||||
"resetFlagCount": "Reset Flag Count",
|
||||
"whyReportingChallenge": "Why are you reporting this Challenge?",
|
||||
"whyReportingChallengePlaceholder": "Reason for report",
|
||||
"abuseFlagModalBodyChallenge": "You should only report a Challenge that violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Submitting a false report is a violation of Habitica's Community Guidelines.",
|
||||
"cannotClose": "This Challenge cannot be closed because one or more players have reported it as inappropriate. A staff members will contact you shortly with instructions. If over 48 hours have passed and you have not heard from them, please email admin@habitica.com for assistance.",
|
||||
"cannotClone": "This Challenge cannot be cloned because one or more players have reported it as inappropriate. A staff member will contact you shortly with instructions. If over 48 hours have passed and you have not heard from them, please email admin@habitica.com for assistance.",
|
||||
"resetFlags": "Reset Flags"
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.",
|
||||
"badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.",
|
||||
"report": "Report",
|
||||
"abuseFlagModalHeading": "Report a Violation",
|
||||
"abuseFlagModalHeading": "Report Violation",
|
||||
"abuseFlagModalBody": "Are you sure you want to report this post? You should <strong>only</strong> report a post that violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Inappropriately reporting a post is a violation of the Community Guidelines and may give you an infraction.",
|
||||
"abuseReported": "Thank you for reporting this violation. The moderators have been notified.",
|
||||
"pmReported": "Thank you for reporting this message.",
|
||||
|
||||
@@ -20,7 +20,6 @@ import csvStringify from '../../libs/csvStringify';
|
||||
import {
|
||||
createTasks,
|
||||
} from '../../libs/tasks';
|
||||
|
||||
import {
|
||||
addUserJoinChallengeNotification,
|
||||
getChallengeGroupResponse,
|
||||
@@ -29,8 +28,12 @@ import {
|
||||
createChallengeQuery,
|
||||
} from '../../libs/challenges';
|
||||
import apiError from '../../libs/apiError';
|
||||
|
||||
import common from '../../../common';
|
||||
import {
|
||||
clearFlags,
|
||||
flagChallenge,
|
||||
notifyOfFlaggedChallenge,
|
||||
} from '../../libs/challenges/reporting';
|
||||
|
||||
const { MAX_SUMMARY_SIZE_FOR_CHALLENGES } = common.constants;
|
||||
|
||||
@@ -366,7 +369,7 @@ api.leaveChallenge = {
|
||||
* @apiParam (Query) {Number} page This parameter can be used to specify the page number
|
||||
for the user challenges result (the initial page is number 0).
|
||||
* @apiParam (Query) {String} [member] If set to `true` it limits results to challenges where the
|
||||
user is a member.
|
||||
user is a member, or the user owns the challenge.
|
||||
* @apiParam (Query) {String} [owned] If set to `owned` it limits results to challenges owned
|
||||
by the user. If set to `not_owned` it limits results
|
||||
to challenges not owned by the user.
|
||||
@@ -394,10 +397,30 @@ api.getUserChallenges = {
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
const CHALLENGES_PER_PAGE = 10;
|
||||
const { page } = req.query;
|
||||
|
||||
const {
|
||||
categories,
|
||||
member,
|
||||
owned,
|
||||
page,
|
||||
search,
|
||||
} = req.query;
|
||||
const { user } = res.locals;
|
||||
|
||||
const query = {
|
||||
$and: [],
|
||||
};
|
||||
|
||||
if (!user.hasPermission('moderator')) {
|
||||
query.$and.push(
|
||||
{
|
||||
$or: [
|
||||
{ flagCount: { $not: { $gt: 1 } } },
|
||||
{ leader: user._id },
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Challenges the user owns
|
||||
const orOptions = [{ leader: user._id }];
|
||||
|
||||
@@ -407,7 +430,7 @@ api.getUserChallenges = {
|
||||
}
|
||||
|
||||
// Challenges in groups user is a member of, plus public challenges
|
||||
if (!req.query.member) {
|
||||
if (!member) {
|
||||
const userGroups = await Group.getGroups({
|
||||
user,
|
||||
types: ['party', 'guilds', 'tavern'],
|
||||
@@ -417,33 +440,29 @@ api.getUserChallenges = {
|
||||
group: { $in: userGroupIds },
|
||||
});
|
||||
}
|
||||
|
||||
const query = {
|
||||
$and: [{ $or: orOptions }],
|
||||
};
|
||||
|
||||
const { owned } = req.query;
|
||||
if (owned) {
|
||||
if (owned === 'not_owned') {
|
||||
query.$and.push({ leader: { $ne: user._id } });
|
||||
}
|
||||
|
||||
if (owned === 'owned') {
|
||||
query.$and.push({ leader: user._id });
|
||||
}
|
||||
if (owned === 'not_owned') {
|
||||
query.leader = { $ne: user._id }; // Show only Challenges user does not own
|
||||
} else if (owned === 'owned') {
|
||||
query.leader = user._id; // Show only Challenges user owns
|
||||
} else {
|
||||
orOptions.push(
|
||||
{ leader: user._id }, // Additionally show Challenges user owns
|
||||
);
|
||||
}
|
||||
|
||||
if (req.query.search) {
|
||||
query.$and.push({ $or: orOptions });
|
||||
|
||||
if (search) {
|
||||
const searchOr = { $or: [] };
|
||||
const searchWords = _.escapeRegExp(req.query.search).split(' ').join('|');
|
||||
const searchWords = _.escapeRegExp(search).split(' ').join('|');
|
||||
const searchQuery = { $regex: new RegExp(`${searchWords}`, 'i') };
|
||||
searchOr.$or.push({ name: searchQuery });
|
||||
searchOr.$or.push({ description: searchQuery });
|
||||
query.$and.push(searchOr);
|
||||
}
|
||||
|
||||
if (req.query.categories) {
|
||||
const categorySlugs = req.query.categories.split(',');
|
||||
if (categories) {
|
||||
const categorySlugs = categories.split(',');
|
||||
query.categories = { $elemMatch: { slug: { $in: categorySlugs } } };
|
||||
}
|
||||
|
||||
@@ -502,7 +521,7 @@ api.getGroupChallenges = {
|
||||
url: '/challenges/groups/:groupId',
|
||||
middlewares: [authWithHeaders({
|
||||
// Some fields (including _id) are always loaded (see middlewares/auth)
|
||||
userFieldsToInclude: ['party', 'guilds'], // Some fields are always loaded (see middlewares/auth)
|
||||
userFieldsToInclude: ['party', 'guilds', 'contributor'], // Some fields are always loaded (see middlewares/auth)
|
||||
})],
|
||||
async handler (req, res) {
|
||||
const { user } = res.locals;
|
||||
@@ -524,7 +543,18 @@ api.getGroupChallenges = {
|
||||
// .populate('leader', nameFields)
|
||||
.exec();
|
||||
|
||||
const resChals = challenges.map(challenge => (new Challenge(challenge)).toJSON());
|
||||
const resChals = challenges.map(challenge => {
|
||||
// filter out challenges that the non-admin user isn't participating in, nor created
|
||||
const nonParticipant = !user.challenges
|
||||
|| (user.challenges
|
||||
&& user.challenges.findIndex(cId => cId === challenge._id) === -1);
|
||||
const isFlaggedForNonAdminUser = challenge.flagCount > 1
|
||||
&& !user.hasPermission('moderator')
|
||||
&& nonParticipant
|
||||
&& challenge.leader !== user._id;
|
||||
|
||||
return isFlaggedForNonAdminUser ? null : (new Challenge(challenge)).toJSON();
|
||||
}).filter(challenge => !!challenge);
|
||||
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
await Promise.all(resChals.map((chal, index) => User
|
||||
@@ -573,6 +603,15 @@ api.getChallenge = {
|
||||
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
const nonParticipant = !user.challenges
|
||||
|| (user.challenges
|
||||
&& user.challenges.findIndex(cId => cId === challenge._id) === -1);
|
||||
const isFlaggedForNonAdminUser = challenge.flagCount > 1
|
||||
&& !user.hasPermission('moderator')
|
||||
&& nonParticipant
|
||||
&& challenge.leader !== user._id;
|
||||
if (isFlaggedForNonAdminUser) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
// Fetching basic group data
|
||||
const group = await Group.getGroup({
|
||||
user, groupId: challenge.group, fields: `${basicGroupFields} purchased`,
|
||||
@@ -828,6 +867,15 @@ api.selectChallengeWinner = {
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
|
||||
|
||||
const nonParticipant = !user.challenges
|
||||
|| (user.challenges
|
||||
&& user.challenges.findIndex(cId => cId === challenge._id) === -1);
|
||||
const isFlaggedForNonAdminUser = challenge.flagCount > 1
|
||||
&& !user.hasPermission('moderator')
|
||||
&& nonParticipant
|
||||
&& challenge.leader !== user._id;
|
||||
if (isFlaggedForNonAdminUser) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
const winner = await User.findOne({ _id: req.params.winnerId }).exec();
|
||||
if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', { userId: req.params.winnerId }));
|
||||
|
||||
@@ -877,6 +925,15 @@ api.cloneChallenge = {
|
||||
const challengeToClone = await Challenge.findOne({ _id: req.params.challengeId }).exec();
|
||||
if (!challengeToClone) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
const nonParticipant = !user.challenges
|
||||
|| (user.challenges
|
||||
&& user.challenges.findIndex(cId => cId === challengeToClone._id) === -1);
|
||||
const isFlaggedForNonAdminUser = challengeToClone.flagCount > 1
|
||||
&& !user.hasPermission('moderator')
|
||||
&& nonParticipant
|
||||
&& challengeToClone.leader !== user._id;
|
||||
if (isFlaggedForNonAdminUser) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
const { savedChal } = await createChallenge(user, req, res);
|
||||
|
||||
const challengeTaskIds = [
|
||||
@@ -910,4 +967,74 @@ api.cloneChallenge = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/challenges/:challengeId/flag Flag a challenge
|
||||
* @apiName FlagChallenge
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam (Path) {UUID} challengeId The _id for the challenge to flag
|
||||
* @apiParam (Body) {String} [comment] Why the message was flagged
|
||||
*
|
||||
* @apiSuccess {Object} data The flagged challenge message
|
||||
*
|
||||
* @apiUse ChallengeNotFound
|
||||
*/
|
||||
api.flagChallenge = {
|
||||
method: 'POST',
|
||||
url: '/challenges/:challengeId/flag',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
const { user } = res.locals;
|
||||
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
|
||||
const validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
const challenge = await Challenge.findOne({ _id: req.params.challengeId }).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
await flagChallenge(challenge, user, res);
|
||||
await notifyOfFlaggedChallenge(challenge, user, req.body.comment);
|
||||
|
||||
res.respond(200, { challenge });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/challenges/:challengeId/clearflags Clears flags on a challenge
|
||||
* @apiName ClearFlagsChallenge
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam (Path) {UUID} challengeId The _id for the challenge to clear flags from
|
||||
*
|
||||
* @apiSuccess {Object} data The flagged challenge message
|
||||
*
|
||||
* @apiUse ChallengeNotFound
|
||||
*/
|
||||
api.clearFlagsChallenge = {
|
||||
method: 'POST',
|
||||
url: '/challenges/:challengeId/clearflags',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
const { user } = res.locals;
|
||||
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
|
||||
const validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
if (!user.hasPermission('moderator')) {
|
||||
throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount'));
|
||||
}
|
||||
|
||||
const challenge = await Challenge.findOne({ _id: req.params.challengeId }).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
await clearFlags(challenge, user);
|
||||
|
||||
res.respond(200, { challenge });
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -91,6 +91,11 @@ export async function createChallenge (user, req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
if (challenge.flagCount > 0) {
|
||||
challenge.flagCount = 0;
|
||||
challenge.flags = {};
|
||||
}
|
||||
|
||||
const results = await Promise.all([challenge.save({
|
||||
validateBeforeSave: false, // already validated
|
||||
}), group.save(), user.save()]);
|
||||
|
||||
82
website/server/libs/challenges/reporting.js
Normal file
82
website/server/libs/challenges/reporting.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import nconf from 'nconf';
|
||||
import { getUserInfo, sendTxn, getGroupUrl } from '../email';
|
||||
import { NotFound } from '../errors';
|
||||
import * as slack from '../slack';
|
||||
|
||||
const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map(email => ({ email, canSend: true }));
|
||||
|
||||
export async function notifyOfFlaggedChallenge (challenge, user, userComment) {
|
||||
const reporterEmailContent = getUserInfo(user, ['email']).email;
|
||||
|
||||
const emailVariables = [
|
||||
{ name: 'CHALLENGE_NAME', content: challenge.name },
|
||||
{ name: 'CHALLENGE_ID', content: challenge._id },
|
||||
|
||||
{ name: 'REPORTER_USERNAME', content: user.profile.name },
|
||||
{ name: 'REPORTER_UUID', content: user._id },
|
||||
{ name: 'REPORTER_EMAIL', content: reporterEmailContent },
|
||||
{ name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId=${user._id}` },
|
||||
{ name: 'REPORTER_COMMENT', content: userComment || '' },
|
||||
|
||||
{ name: 'AUTHOR_UUID', content: challenge.leader._id },
|
||||
{ name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${challenge.leader._id}` },
|
||||
];
|
||||
|
||||
sendTxn(FLAG_REPORT_EMAILS, 'flag-report-to-mods', emailVariables);
|
||||
|
||||
slack.sendChallengeFlagNotification({
|
||||
flagger: user,
|
||||
challenge,
|
||||
userComment,
|
||||
});
|
||||
}
|
||||
|
||||
export async function flagChallenge (challenge, user, res) {
|
||||
if (challenge.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageChallengeFlagAlreadyReported'));
|
||||
|
||||
challenge.flags[user._id] = true;
|
||||
challenge.markModified('flags');
|
||||
|
||||
if (user.contributor.admin) {
|
||||
// Arbitrary amount, higher than 2
|
||||
challenge.flagCount = 5;
|
||||
} else {
|
||||
challenge.flagCount += 1;
|
||||
}
|
||||
|
||||
await challenge.save();
|
||||
}
|
||||
|
||||
export async function clearFlags (challenge, user) {
|
||||
challenge.flagCount = 0;
|
||||
if (user.contributor.admin) { // let's get this to a proper "permissions" check later
|
||||
challenge.flags = {};
|
||||
challenge.markModified('flags');
|
||||
} else if (challenge.flags[user._id]) {
|
||||
challenge.flags[user._id] = false;
|
||||
challenge.markModified('flags');
|
||||
}
|
||||
await challenge.save();
|
||||
|
||||
const adminEmailContent = getUserInfo(user, ['email']).email;
|
||||
const challengeUrl = `/challenges/${challenge._id}`;
|
||||
|
||||
const groupUrl = getGroupUrl({ _id: challenge.group, type: 'guild' });
|
||||
|
||||
sendTxn(FLAG_REPORT_EMAILS, 'unflag-report-to-mods', [
|
||||
{ name: 'ADMIN_USERNAME', content: user.profile.name },
|
||||
{ name: 'ADMIN_UUID', content: user._id },
|
||||
{ name: 'ADMIN_EMAIL', content: adminEmailContent },
|
||||
{ name: 'ADMIN_MODAL_URL', content: `/static/front/#?memberId=${user._id}` },
|
||||
|
||||
{ name: 'AUTHOR_UUID', content: challenge.leader },
|
||||
{ name: 'AUTHOR_EMAIL', content: adminEmailContent },
|
||||
{ name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${challenge.leader}` },
|
||||
|
||||
{ name: 'CHALLENGE_NAME', content: challenge.name },
|
||||
{ name: 'CHALLENGE_ID', content: challenge._id },
|
||||
{ name: 'CHALLENGE_URL', content: challengeUrl },
|
||||
|
||||
{ name: 'GROUP_URL', content: groupUrl },
|
||||
]);
|
||||
}
|
||||
@@ -176,6 +176,41 @@ function sendInboxFlagNotification ({
|
||||
.catch(err => logger.error(err, 'Error while sending flag data to Slack.'));
|
||||
}
|
||||
|
||||
function sendChallengeFlagNotification ({
|
||||
flagger,
|
||||
challenge,
|
||||
userComment,
|
||||
}) {
|
||||
if (SKIP_FLAG_METHODS) {
|
||||
return;
|
||||
}
|
||||
const titleLink = `${BASE_URL}/challenges/${challenge.id}`;
|
||||
const title = `Flag in challenge "${challenge.name}"`;
|
||||
let text = `${flagger.profile.name} (${flagger.id}; language: ${flagger.preferences.language}) flagged a challenge`;
|
||||
const footer = '';
|
||||
|
||||
if (userComment) {
|
||||
text += ` and commented: ${userComment}`;
|
||||
}
|
||||
|
||||
const challengeText = challenge.summary;
|
||||
|
||||
flagSlack.send({
|
||||
text,
|
||||
attachments: [{
|
||||
fallback: 'Flag Message',
|
||||
color: 'danger',
|
||||
title,
|
||||
title_link: titleLink,
|
||||
text: challengeText,
|
||||
footer,
|
||||
mrkdwn_in: [
|
||||
'text',
|
||||
],
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
function sendProfileFlagNotification ({
|
||||
reporter,
|
||||
flaggedUser,
|
||||
@@ -339,6 +374,7 @@ function sendSlurNotification ({
|
||||
export {
|
||||
sendFlagNotification,
|
||||
sendInboxFlagNotification,
|
||||
sendChallengeFlagNotification,
|
||||
sendProfileFlagNotification,
|
||||
sendSubscriptionNotification,
|
||||
sendShadowMutedPostNotification,
|
||||
|
||||
@@ -46,6 +46,8 @@ const schema = new Schema({
|
||||
slug: { $type: String },
|
||||
name: { $type: String },
|
||||
}],
|
||||
flags: { $type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
flagCount: { $type: Number, default: 0, min: 0 },
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
|
||||
Reference in New Issue
Block a user