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:
Natalie
2023-10-24 10:24:56 -04:00
committed by GitHub
parent 8537793308
commit 581271e930
14 changed files with 820 additions and 65 deletions

View File

@@ -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'),
});
});
});

View File

@@ -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);
});
});

View File

@@ -1,5 +1,6 @@
<template> <template>
<div class="row"> <div class="row">
<report-challenge-modal />
<challenge-modal @updatedChallenge="updatedChallenge" /> <challenge-modal @updatedChallenge="updatedChallenge" />
<leave-challenge-modal <leave-challenge-modal
:challenge-id="challenge._id" :challenge-id="challenge._id"
@@ -9,11 +10,27 @@
:members="members" :members="members"
:challenge-id="challenge._id" :challenge-id="challenge._id"
:prize="challenge.prize" :prize="challenge.prize"
:flag-count="challenge.flagCount"
/> />
<challenge-member-progress-modal :challenge-id="challenge._id" /> <challenge-member-progress-modal :challenge-id="challenge._id" />
<div class="col-12 col-md-8 standard-page"> <div class="col-12 col-md-8 standard-page">
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <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> <h1 v-markdown="challenge.name"></h1>
<div> <div>
<span class="mr-1 ml-0 d-block"> <span class="mr-1 ml-0 d-block">
@@ -41,7 +58,7 @@
createdBy string (helps with RTL languages)--> createdBy string (helps with RTL languages)-->
<!-- @TODO: Implement in V2 strong.margin-left <!-- @TODO: Implement in V2 strong.margin-left
(v-once).svg-icon.calendar-icon(v-html="icons.calendarIcon") (v-once).svg-icon.calendar-icon(v-html="icons.calendarIcon")
| {{$t('endDate')}} {{$t('endDate')}}
// "endDate": "End Date: <% endDate %>",--> // "endDate": "End Date: <% endDate %>",-->
<!-- span {{challenge.endDate}}--> <!-- span {{challenge.endDate}}-->
</div> </div>
@@ -169,13 +186,16 @@
v-if="isLeader || isAdmin" v-if="isLeader || isAdmin"
class="button-container" class="button-container"
> >
<button <div>
v-once <button
class="btn btn-primary" class="btn"
@click="cloneChallenge()" :disabled="flaggedAndHidden"
> :class="flaggedAndHidden ? 'disabled btn-disabled' : 'btn-primary'"
{{ $t('clone') }} @click="cloneChallenge()"
</button> >
{{ $t('clone') }}
</button>
</div>
</div> </div>
<div <div
v-if="isLeader || isAdmin" v-if="isLeader || isAdmin"
@@ -201,6 +221,17 @@
{{ $t('endChallenge') }} {{ $t('endChallenge') }}
</button> </button>
</div> </div>
<div
class="button-container"
>
<button
v-once
class="btn btn-danger"
@click="reportChallenge()"
>
{{ $t('report') }}
</button>
</div>
<div> <div>
<sidebar-section :title="$t('challengeSummary')"> <sidebar-section :title="$t('challengeSummary')">
<p v-markdown="challenge.summary"></p> <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 { .calendar-icon {
width: 12px; width: 12px;
display: inline-block; display: inline-block;
@@ -312,6 +354,15 @@
margin-right: .5em; margin-right: .5em;
} }
} }
.flagged {
margin-left: 0em;
color: $red-10;
span {
margin-left: 0em;
}
}
</style> </style>
<script> <script>
@@ -332,6 +383,7 @@ import challengeModal from './challengeModal';
import challengeMemberProgressModal from './challengeMemberProgressModal'; import challengeMemberProgressModal from './challengeMemberProgressModal';
import challengeMemberSearchMixin from '@/mixins/challengeMemberSearch'; import challengeMemberSearchMixin from '@/mixins/challengeMemberSearch';
import leaveChallengeModal from './leaveChallengeModal'; import leaveChallengeModal from './leaveChallengeModal';
import reportChallengeModal from './reportChallengeModal';
import sidebarSection from '../sidebarSection'; import sidebarSection from '../sidebarSection';
import userLink from '../userLink'; import userLink from '../userLink';
import groupLink from '../groupLink'; import groupLink from '../groupLink';
@@ -350,6 +402,7 @@ export default {
components: { components: {
closeChallengeModal, closeChallengeModal,
leaveChallengeModal, leaveChallengeModal,
reportChallengeModal,
challengeModal, challengeModal,
challengeMemberProgressModal, challengeMemberProgressModal,
memberSearchDropdown, memberSearchDropdown,
@@ -401,6 +454,20 @@ export default {
canJoin () { canJoin () {
return !this.isMember; 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: { watch: {
'challenge.name': { 'challenge.name': {
@@ -589,6 +656,14 @@ export default {
challenge: this.challenge, 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> </script>

View File

@@ -366,7 +366,6 @@
} }
} }
} }
} }
</style> </style>

View File

@@ -2,7 +2,7 @@
<div> <div>
<b-modal <b-modal
id="close-challenge-modal" id="close-challenge-modal"
:title="$t('createGuild')" :title="$t('endChallenge')"
size="md" size="md"
> >
<div <div
@@ -17,31 +17,42 @@
</h2> </h2>
</div> </div>
<div class="row text-center"> <div class="row text-center">
<div class="col-12"> <span
<div class="support-habitica"> v-if="isFlagged"
<!-- @TODO: Add challenge achievement badge here--> 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> <div class="col-12">
<div class="col-12"> <strong v-once>{{ $t('selectChallengeWinnersDescription') }}</strong>
<strong v-once>{{ $t('selectChallengeWinnersDescription') }}</strong> </div>
</div> <div class="col-12">
<div class="col-12"> <member-search-dropdown
<member-search-dropdown :text="winnerText"
:text="winnerText" :members="members"
:members="members" :challenge-id="challengeId"
:challenge-id="challengeId" @member-selected="selectMember"
@member-selected="selectMember" />
/> </div>
</div> <div class="col-12">
<div class="col-12"> <button
<button v-once
v-once class="btn btn-primary"
class="btn btn-primary" @click="closeChallenge"
@click="closeChallenge" >
> {{ $t('awardWinners') }}
{{ $t('awardWinners') }} </button>
</button> </div>
</div> </span>
<div class="col-12"> <div class="col-12">
<hr> <hr>
<div class="or"> <div class="or">
@@ -123,7 +134,7 @@ export default {
components: { components: {
memberSearchDropdown, memberSearchDropdown,
}, },
props: ['challengeId', 'members', 'prize'], props: ['challengeId', 'members', 'prize', 'flagCount'],
data () { data () {
return { return {
winner: {}, winner: {},
@@ -134,6 +145,9 @@ export default {
if (!this.winner.profile) return this.$t('selectMember'); if (!this.winner.profile) return this.$t('selectMember');
return this.winner.profile.name; return this.winner.profile.name;
}, },
isFlagged () {
return this.flagCount > 0;
},
}, },
methods: { methods: {
selectMember (member) { selectMember (member) {

View File

@@ -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>

View File

@@ -97,3 +97,15 @@ export async function selectChallengeWinner (store, payload) {
return response.data.data; 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;
}

View File

@@ -88,13 +88,23 @@
"summaryRequired": "Summary is required", "summaryRequired": "Summary is required",
"summaryTooLong": "Summary is too long", "summaryTooLong": "Summary is too long",
"descriptionRequired": "Description is required", "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", "categoiresRequired": "One or more categories must be selected",
"viewProgressOf": "View Progress Of", "viewProgressOf": "View Progress Of",
"viewProgress": "View Progress", "viewProgress": "View Progress",
"selectMember": "Select Member", "selectMember": "Select Member",
"confirmKeepChallengeTasks": "Do you want to keep challenge tasks?", "confirmKeepChallengeTasks": "Do you want to keep Challenge tasks?",
"selectParticipant": "Select a Participant", "selectParticipant": "Select a Participant",
"yourReward": "Your Reward", "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"
} }

View File

@@ -103,7 +103,7 @@
"cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.",
"badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.", "badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.",
"report": "Report", "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.", "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.", "abuseReported": "Thank you for reporting this violation. The moderators have been notified.",
"pmReported": "Thank you for reporting this message.", "pmReported": "Thank you for reporting this message.",

View File

@@ -20,7 +20,6 @@ import csvStringify from '../../libs/csvStringify';
import { import {
createTasks, createTasks,
} from '../../libs/tasks'; } from '../../libs/tasks';
import { import {
addUserJoinChallengeNotification, addUserJoinChallengeNotification,
getChallengeGroupResponse, getChallengeGroupResponse,
@@ -29,8 +28,12 @@ import {
createChallengeQuery, createChallengeQuery,
} from '../../libs/challenges'; } from '../../libs/challenges';
import apiError from '../../libs/apiError'; import apiError from '../../libs/apiError';
import common from '../../../common'; import common from '../../../common';
import {
clearFlags,
flagChallenge,
notifyOfFlaggedChallenge,
} from '../../libs/challenges/reporting';
const { MAX_SUMMARY_SIZE_FOR_CHALLENGES } = common.constants; 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 * @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). 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 * @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 * @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 by the user. If set to `not_owned` it limits results
to challenges not owned by the user. to challenges not owned by the user.
@@ -394,10 +397,30 @@ api.getUserChallenges = {
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
const CHALLENGES_PER_PAGE = 10; const CHALLENGES_PER_PAGE = 10;
const { page } = req.query; const {
categories,
member,
owned,
page,
search,
} = req.query;
const { user } = res.locals; 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 // Challenges the user owns
const orOptions = [{ leader: user._id }]; const orOptions = [{ leader: user._id }];
@@ -407,7 +430,7 @@ api.getUserChallenges = {
} }
// Challenges in groups user is a member of, plus public challenges // Challenges in groups user is a member of, plus public challenges
if (!req.query.member) { if (!member) {
const userGroups = await Group.getGroups({ const userGroups = await Group.getGroups({
user, user,
types: ['party', 'guilds', 'tavern'], types: ['party', 'guilds', 'tavern'],
@@ -417,33 +440,29 @@ api.getUserChallenges = {
group: { $in: userGroupIds }, group: { $in: userGroupIds },
}); });
} }
if (owned === 'not_owned') {
const query = { query.leader = { $ne: user._id }; // Show only Challenges user does not own
$and: [{ $or: orOptions }], } else if (owned === 'owned') {
}; query.leader = user._id; // Show only Challenges user owns
} else {
const { owned } = req.query; orOptions.push(
if (owned) { { leader: user._id }, // Additionally show Challenges user owns
if (owned === 'not_owned') { );
query.$and.push({ leader: { $ne: user._id } });
}
if (owned === 'owned') {
query.$and.push({ leader: user._id });
}
} }
if (req.query.search) { query.$and.push({ $or: orOptions });
if (search) {
const searchOr = { $or: [] }; const searchOr = { $or: [] };
const searchWords = _.escapeRegExp(req.query.search).split(' ').join('|'); const searchWords = _.escapeRegExp(search).split(' ').join('|');
const searchQuery = { $regex: new RegExp(`${searchWords}`, 'i') }; const searchQuery = { $regex: new RegExp(`${searchWords}`, 'i') };
searchOr.$or.push({ name: searchQuery }); searchOr.$or.push({ name: searchQuery });
searchOr.$or.push({ description: searchQuery }); searchOr.$or.push({ description: searchQuery });
query.$and.push(searchOr); query.$and.push(searchOr);
} }
if (req.query.categories) { if (categories) {
const categorySlugs = req.query.categories.split(','); const categorySlugs = categories.split(',');
query.categories = { $elemMatch: { slug: { $in: categorySlugs } } }; query.categories = { $elemMatch: { slug: { $in: categorySlugs } } };
} }
@@ -502,7 +521,7 @@ api.getGroupChallenges = {
url: '/challenges/groups/:groupId', url: '/challenges/groups/:groupId',
middlewares: [authWithHeaders({ middlewares: [authWithHeaders({
// Some fields (including _id) are always loaded (see middlewares/auth) // 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) { async handler (req, res) {
const { user } = res.locals; const { user } = res.locals;
@@ -524,7 +543,18 @@ api.getGroupChallenges = {
// .populate('leader', nameFields) // .populate('leader', nameFields)
.exec(); .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 // 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 await Promise.all(resChals.map((chal, index) => User
@@ -573,6 +603,15 @@ api.getChallenge = {
if (!challenge) throw new NotFound(res.t('challengeNotFound')); 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 // Fetching basic group data
const group = await Group.getGroup({ const group = await Group.getGroup({
user, groupId: challenge.group, fields: `${basicGroupFields} purchased`, user, groupId: challenge.group, fields: `${basicGroupFields} purchased`,
@@ -828,6 +867,15 @@ api.selectChallengeWinner = {
if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); 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(); 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 })); 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(); const challengeToClone = await Challenge.findOne({ _id: req.params.challengeId }).exec();
if (!challengeToClone) throw new NotFound(res.t('challengeNotFound')); 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 { savedChal } = await createChallenge(user, req, res);
const challengeTaskIds = [ 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; export default api;

View File

@@ -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({ const results = await Promise.all([challenge.save({
validateBeforeSave: false, // already validated validateBeforeSave: false, // already validated
}), group.save(), user.save()]); }), group.save(), user.save()]);

View 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 },
]);
}

View File

@@ -176,6 +176,41 @@ function sendInboxFlagNotification ({
.catch(err => logger.error(err, 'Error while sending flag data to Slack.')); .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 ({ function sendProfileFlagNotification ({
reporter, reporter,
flaggedUser, flaggedUser,
@@ -339,6 +374,7 @@ function sendSlurNotification ({
export { export {
sendFlagNotification, sendFlagNotification,
sendInboxFlagNotification, sendInboxFlagNotification,
sendChallengeFlagNotification,
sendProfileFlagNotification, sendProfileFlagNotification,
sendSubscriptionNotification, sendSubscriptionNotification,
sendShadowMutedPostNotification, sendShadowMutedPostNotification,

View File

@@ -46,6 +46,8 @@ const schema = new Schema({
slug: { $type: String }, slug: { $type: String },
name: { $type: String }, name: { $type: String },
}], }],
flags: { $type: mongoose.Schema.Types.Mixed, default: {} },
flagCount: { $type: Number, default: 0, min: 0 },
}, { }, {
strict: true, strict: true,
minimize: false, // So empty objects are returned minimize: false, // So empty objects are returned