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>
<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,14 +186,17 @@
v-if="isLeader || isAdmin"
class="button-container"
>
<div>
<button
v-once
class="btn btn-primary"
class="btn"
:disabled="flaggedAndHidden"
:class="flaggedAndHidden ? 'disabled btn-disabled' : 'btn-primary'"
@click="cloneChallenge()"
>
{{ $t('clone') }}
</button>
</div>
</div>
<div
v-if="isLeader || isAdmin"
class="button-container"
@@ -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>

View File

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

View File

@@ -2,7 +2,7 @@
<div>
<b-modal
id="close-challenge-modal"
:title="$t('createGuild')"
:title="$t('endChallenge')"
size="md"
>
<div
@@ -17,6 +17,16 @@
</h2>
</div>
<div class="row text-center">
<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-->
@@ -42,6 +52,7 @@
{{ $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) {

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;
}
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",
"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"
}

View File

@@ -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.",

View File

@@ -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 } });
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 (owned === 'owned') {
query.$and.push({ leader: user._id });
}
}
query.$and.push({ $or: orOptions });
if (req.query.search) {
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;

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({
validateBeforeSave: false, // already validated
}), 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.'));
}
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,

View File

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