Merge branch 'report-multiple-events' into 'main'

feat(reports): allow reports to hold multiple events

See merge request framasoft/mobilizon!1407
This commit is contained in:
Thomas Citharel 2023-08-31 21:16:40 +00:00
commit e3d9a76074
27 changed files with 758 additions and 249 deletions

View file

@ -4,7 +4,7 @@
:class="{ :class="{
reply: comment.inReplyToComment, reply: comment.inReplyToComment,
'bg-mbz-purple-50 dark:bg-mbz-purple-500': comment.isAnnouncement, 'bg-mbz-purple-50 dark:bg-mbz-purple-500': comment.isAnnouncement,
'bg-mbz-bluegreen-50 dark:bg-mbz-bluegreen-600': commentSelected, '!bg-mbz-bluegreen-50 dark:!bg-mbz-bluegreen-600': commentSelected,
'shadow-none': !rootComment, 'shadow-none': !rootComment,
}" }"
> >
@ -62,6 +62,7 @@
class="cursor-pointer flex hover:bg-zinc-300 dark:hover:bg-zinc-600 rounded p-1" class="cursor-pointer flex hover:bg-zinc-300 dark:hover:bg-zinc-600 rounded p-1"
v-if=" v-if="
currentActor?.id && currentActor?.id &&
!readOnly &&
event.options.commentModeration !== CommentModeration.CLOSED && event.options.commentModeration !== CommentModeration.CLOSED &&
!comment.deletedAt !comment.deletedAt
" "
@ -70,7 +71,7 @@
<Reply /> <Reply />
<span>{{ t("Reply") }}</span> <span>{{ t("Reply") }}</span>
</button> </button>
<o-dropdown aria-role="list"> <o-dropdown aria-role="list" v-show="!readOnly">
<template #trigger> <template #trigger>
<button <button
class="cursor-pointer flex hover:bg-zinc-300 dark:hover:bg-zinc-600 rounded p-1" class="cursor-pointer flex hover:bg-zinc-300 dark:hover:bg-zinc-600 rounded p-1"
@ -221,7 +222,7 @@ import {
ref, ref,
nextTick, nextTick,
} from "vue"; } from "vue";
import { useRoute } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue"; import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Delete from "vue-material-design-icons/Delete.vue"; import Delete from "vue-material-design-icons/Delete.vue";
@ -235,6 +236,9 @@ import ReportModal from "@/components/Report/ReportModal.vue";
import { useCreateReport } from "@/composition/apollo/report"; import { useCreateReport } from "@/composition/apollo/report";
import { Snackbar } from "@/plugins/snackbar"; import { Snackbar } from "@/plugins/snackbar";
import { useProgrammatic } from "@oruga-ui/oruga-next"; import { useProgrammatic } from "@oruga-ui/oruga-next";
import RouteName from "@/router/name";
const router = useRouter();
const Editor = defineAsyncComponent( const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue") () => import("@/components/TextEditor.vue")
@ -246,10 +250,13 @@ const props = withDefaults(
event: IEvent; event: IEvent;
currentActor: IPerson; currentActor: IPerson;
rootComment?: boolean; rootComment?: boolean;
readOnly: boolean;
}>(), }>(),
{ rootComment: true } { rootComment: true, readOnly: false }
); );
const event = computed(() => props.event);
const emit = defineEmits<{ const emit = defineEmits<{
(e: "create-comment", comment: IComment): void; (e: "create-comment", comment: IComment): void;
(e: "delete-comment", comment: IComment): void; (e: "delete-comment", comment: IComment): void;
@ -319,7 +326,12 @@ const commentId = computed((): string => {
const commentURL = computed((): string => { const commentURL = computed((): string => {
if (!props.comment.local && props.comment.url) return props.comment.url; if (!props.comment.local && props.comment.url) return props.comment.url;
return `#${commentId.value}`; return (
router.resolve({
name: RouteName.EVENT,
params: { uuid: event.value.uuid },
}).href + `#${commentId.value}`
);
}); });
const reportModal = (): void => { const reportModal = (): void => {
@ -352,7 +364,6 @@ const reportComment = async (
): Promise<void> => { ): Promise<void> => {
if (!props.comment.actor) return; if (!props.comment.actor) return;
createReportMutation({ createReportMutation({
eventId: props.event.id,
reportedId: props.comment.actor?.id ?? "", reportedId: props.comment.actor?.id ?? "",
commentsIds: [props.comment.id ?? ""], commentsIds: [props.comment.id ?? ""],
content, content,

View file

@ -648,7 +648,7 @@ const reportEvent = async (
if (!organizer.value) return; if (!organizer.value) return;
createReportMutation({ createReportMutation({
eventId: event.value?.id ?? "", eventsIds: [event.value?.id ?? ""],
reportedId: organizer.value?.id ?? "", reportedId: organizer.value?.id ?? "",
content, content,
forward, forward,

View file

@ -5,7 +5,7 @@
> >
<div class="flex justify-between gap-1 border-b p-2"> <div class="flex justify-between gap-1 border-b p-2">
<div class="flex gap-1"> <div class="flex gap-1">
<figure class="" v-if="report.reported.avatar"> <figure class="" v-if="report.reported?.avatar">
<img <img
alt="" alt=""
:src="report.reported.avatar.url" :src="report.reported.avatar.url"
@ -16,14 +16,20 @@
</figure> </figure>
<AccountCircle v-else :size="24" /> <AccountCircle v-else :size="24" />
<div class=""> <div class="">
<p class="" v-if="report.reported.name">{{ report.reported.name }}</p> <p class="" v-if="report.reported?.name">
<p class="text-zinc-700 dark:text-zinc-100 text-sm"> {{ report.reported.name }}
</p>
<p
class="text-zinc-700 dark:text-zinc-100 text-sm"
v-else-if="report.reported?.preferredUsername"
>
@{{ usernameWithDomain(report.reported) }} @{{ usernameWithDomain(report.reported) }}
</p> </p>
<p v-else>{{ t("Unknown actor") }}</p>
</div> </div>
</div> </div>
<div> <div>
<p v-if="report.reported.suspended" class="text-red-700 font-bold"> <p v-if="report.reported?.suspended" class="text-red-700 font-bold">
{{ t("Suspended") }} {{ t("Suspended") }}
</p> </p>
</div> </div>
@ -31,7 +37,7 @@
<div class="p-2"> <div class="p-2">
<div class=""> <div class="">
<span v-if="report.reporter.type === ActorType.APPLICATION"> <span v-if="report.reporter?.type === ActorType.APPLICATION">
{{ {{
t("Reported by someone on {domain}", { t("Reported by someone on {domain}", {
domain: report.reporter.domain, domain: report.reporter.domain,
@ -40,19 +46,22 @@
</span> </span>
<span <span
v-if=" v-if="
report.reporter.preferredUsername === 'anonymous' && report.reporter?.preferredUsername === 'anonymous' &&
!report.reporter.domain !report.reporter?.domain
" "
> >
{{ t("Reported by someone anonymously") }} {{ t("Reported by someone anonymously") }}
</span> </span>
<span v-else> <span v-else-if="report.reporter?.preferredUsername">
{{ {{
t("Reported by {reporter}", { t("Reported by {reporter}", {
reporter: usernameWithDomain(report.reporter), reporter: usernameWithDomain(report.reporter),
}) })
}} }}
</span> </span>
<span v-else>
{{ t("Reported by an unknown actor") }}
</span>
</div> </div>
<div class="" v-if="report.content" v-html="report.content" /> <div class="" v-if="report.content" v-html="report.content" />
</div> </div>

View file

@ -5,7 +5,7 @@ export function useCreateReport() {
return useMutation< return useMutation<
{ createReport: { id: string } }, { createReport: { id: string } },
{ {
eventId?: string; eventsIds?: string[];
reportedId: string; reportedId: string;
content?: string; content?: string;
commentsIds?: string[]; commentsIds?: string[];

View file

@ -20,7 +20,7 @@ export const REPORTS = gql`
...ActorFragment ...ActorFragment
suspended suspended
} }
event { events {
id id
uuid uuid
title title
@ -42,11 +42,19 @@ const REPORT_FRAGMENT = gql`
id id
reported { reported {
...ActorFragment ...ActorFragment
suspended
... on Person {
user {
id
disabled
}
}
} }
reporter { reporter {
...ActorFragment ...ActorFragment
suspended
} }
event { events {
id id
uuid uuid
title title
@ -69,6 +77,14 @@ const REPORT_FRAGMENT = gql`
actor { actor {
...ActorFragment ...ActorFragment
} }
updatedAt
deletedAt
uuid
event {
id
uuid
title
}
} }
notes { notes {
id id
@ -97,14 +113,14 @@ export const REPORT = gql`
export const CREATE_REPORT = gql` export const CREATE_REPORT = gql`
mutation CreateReport( mutation CreateReport(
$eventId: ID $eventsIds: [ID]
$reportedId: ID! $reportedId: ID!
$content: String $content: String
$commentsIds: [ID] $commentsIds: [ID]
$forward: Boolean $forward: Boolean
) { ) {
createReport( createReport(
eventId: $eventId eventsIds: $eventsIds
reportedId: $reportedId reportedId: $reportedId
content: $content content: $content
commentsIds: $commentsIds commentsIds: $commentsIds

View file

@ -78,8 +78,6 @@
"Date parameters": "Date parameters", "Date parameters": "Date parameters",
"Date": "Date", "Date": "Date",
"Default": "Default", "Default": "Default",
"Delete Comment": "Delete Comment",
"Delete Event": "Delete Event",
"Delete account": "Delete account", "Delete account": "Delete account",
"Delete event": "Delete event", "Delete event": "Delete event",
"Delete everything": "Delete everything", "Delete everything": "Delete everything",
@ -1582,5 +1580,30 @@
"This application will be allowed to list your suggested group events": "This application will be allowed to list your suggested group events", "This application will be allowed to list your suggested group events": "This application will be allowed to list your suggested group events",
"{profile} joined the the event {event}.": "{profile} joined the the event {event}.", "{profile} joined the the event {event}.": "{profile} joined the the event {event}.",
"You joined the event {event}.": "You joined the event {event}.", "You joined the event {event}.": "You joined the event {event}.",
"An anonymous profile joined the event {event}.": "An anonymous profile joined the event {event}." "An anonymous profile joined the event {event}.": "An anonymous profile joined the event {event}.",
"Delete event and resolve report": "Delete event and resolve report",
"No content found": "No content found",
"Maybe the content was removed by the author or a moderator": "Maybe the content was removed by the author or a moderator",
"This will also resolve the report.": "This will also resolve the report.",
"Are you sure you want to <b>delete</b> this event? <b>This action cannot be undone</b>. You may want to engage the discussion with the event creator and ask them to edit their event instead.": "Are you sure you want to <b>delete</b> this event? <b>This action cannot be undone</b>. You may want to engage the discussion with the event creator and ask them to edit their event instead.",
"Are you sure you want to <b>delete</b> this comment? <b>This action cannot be undone</b>.": "Are you sure you want to <b>delete</b> this comment? <b>This action cannot be undone</b>.",
"Delete comment and resolve report": "Delete comment and resolve report",
"Delete comment": "Delete comment",
"Event deleted and report resolved": "Event deleted and report resolved",
"Event deleted": "Event deleted",
"Comment deleted and report resolved": "Comment deleted and report resolved",
"Comment under event {eventTitle}": "Comment under event {eventTitle}",
"Do you really want to suspend this profile? All of the profiles content will be deleted.": "Do you really want to suspend this profile? All of the profiles content will be deleted.",
"There will be no way to restore the profile's data!": "There will be no way to restore the profile's data!",
"Suspend the profile": "Suspend the profile",
"The following user's profiles will be deleted, with all their data:": "The following user's profiles will be deleted, with all their data:",
"Do you really want to suspend the account « {emailAccount} » ?": "Do you really want to suspend the account « {emailAccount} » ?",
"There will be no way to restore the user's data!": "There will be no way to restore the user's data!",
"User suspended and report resolved": "User suspended and report resolved",
"Profile suspended and report resolved": "Profile suspended and report resolved",
"{profileName} (suspended)": "{profileName} (suspended)",
"Reported by an unknown actor": "Reported by an unknown actor",
"Reported at": "Reported at",
"Updated at": "Updated at",
"Suspend the profile?": "Suspend the profile?"
} }

View file

@ -1578,5 +1578,30 @@
"This application will be allowed to list your suggested group events": "Cetta application sera autorisée à lister les événements de vos groupes qui vous sont suggérés", "This application will be allowed to list your suggested group events": "Cetta application sera autorisée à lister les événements de vos groupes qui vous sont suggérés",
"{profile} joined the the event {event}.": "{profile} a rejoint l'événement {event}.", "{profile} joined the the event {event}.": "{profile} a rejoint l'événement {event}.",
"You joined the event {event}.": "Vous avez rejoint l'événement {event}.", "You joined the event {event}.": "Vous avez rejoint l'événement {event}.",
"An anonymous profile joined the event {event}.": "Un profil anonyme a rejoint l'événement {event}." "An anonymous profile joined the event {event}.": "Un profil anonyme a rejoint l'événement {event}.",
"Delete event and resolve report": "Supprimer l'événement et résoudre le signalement",
"No content found": "Aucun contenu trouvé",
"Maybe the content was removed by the author or a moderator": "Peut-être que le contenu a été supprimé par l'auteur·ice ou un·e modérateur·ice",
"This will also resolve the report.": "Cela résoudra également le signalement.",
"Are you sure you want to <b>delete</b> this event? <b>This action cannot be undone</b>. You may want to engage the discussion with the event creator and ask them to edit their event instead.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> cet événement ? Cette action n'est pas réversible. Vous voulez peut-être engager la discussion avec le créateur de l'événement et lui demander de modifier son événement à la place.",
"Are you sure you want to <b>delete</b> this comment? <b>This action cannot be undone</b>.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> ce commentaire ? <b>Cette action ne peut pas être annulée.</b>",
"Delete comment and resolve report": "Supprimer le commentaire et résoudre le signalement",
"Delete comment": "Supprimer le commentaire",
"Event deleted and report resolved": "Événement supprimé et signalement résolu",
"Event deleted": "Événement supprimé",
"Comment deleted and report resolved": "Commentaire supprimé et signalement résolu",
"Comment under event {eventTitle}": "Commentaire sous l'événement {eventTitle}",
"Suspend the profile?": "Suspendre le profil ?",
"Do you really want to suspend this profile? All of the profiles content will be deleted.": "Voulez-vous vraiment suspendre ce profil ? Tout le contenu du profil sera supprimé.",
"There will be no way to restore the profile's data!": "Il n'y aura aucun moyen de restorer les données du profil !",
"Suspend the profile": "Suspendre le profil",
"The following user's profiles will be deleted, with all their data:": "Les profils suivants de l'utilisateur·ice seront supprimés, avec toutes leurs données :",
"Do you really want to suspend the account « {emailAccount} » ?": "Voulez-vous vraiment suspendre le compte « {emailAccount} » ?",
"There will be no way to restore the user's data!": "Il n'y aura aucun moyen de restorer les données de l'utilisateur·ice !",
"User suspended and report resolved": "Utilisateur suspendu et signalement résolu",
"Profile suspended and report resolved": "Profil suspendu et signalement résolu",
"{profileName} (suspended)": "{profileName} (suspendu·e)",
"Reported by an unknown actor": "Signalé par un·e acteur·ice inconnu·e",
"Reported at": "Signalé à",
"Updated at": "Mis à jour à"
} }

View file

@ -14,9 +14,9 @@ export interface IReportNote extends IActionLogObject {
} }
export interface IReport extends IActionLogObject { export interface IReport extends IActionLogObject {
id: string; id: string;
reported: IActor; reported: IActor | undefined;
reporter: IPerson; reporter: IPerson;
event?: IEvent; events?: IEvent[];
comments: IComment[]; comments: IComment[];
content: string; content: string;
notes: IReportNote[]; notes: IReportNote[];

View file

@ -65,7 +65,7 @@
<section class="w-full"> <section class="w-full">
<table class="table w-full"> <table class="table w-full">
<tbody> <tbody>
<tr v-if="report.reported.type === ActorType.GROUP"> <tr v-if="report.reported?.type === ActorType.GROUP">
<td>{{ t("Reported group") }}</td> <td>{{ t("Reported group") }}</td>
<td> <td>
<router-link <router-link
@ -84,33 +84,70 @@
</router-link> </router-link>
</td> </td>
</tr> </tr>
<tr v-else> <tr v-else-if="report.reported?.type === ActorType.PERSON">
<td> <td>
{{ t("Reported identity") }} {{ t("Reported identity") }}
</td> </td>
<td> <td class="flex items-center justify-between pr-6">
<router-link <router-link
:to="{ :to="{
name: RouteName.ADMIN_PROFILE, name: RouteName.ADMIN_PROFILE,
params: { id: report.reported.id }, params: { id: report.reported.id },
}" }"
class="inline-flex gap-1"
> >
<img <img
v-if="report.reported.avatar" v-if="report.reported.avatar"
class="image" class="image rounded-full"
:src="report.reported.avatar.url" :src="report.reported.avatar.url"
alt="" alt=""
/> />
{{ displayNameAndUsername(report.reported) }} <template v-if="report.reported.suspended">
<i18n-t keypath="{profileName} (suspended)">
<template #profileName>
{{ displayNameAndUsername(report.reported) }}
</template>
</i18n-t>
</template>
<template v-else>{{
displayNameAndUsername(report.reported)
}}</template>
</router-link> </router-link>
<o-button
v-if="report.reported.domain && !report.reported.suspended"
variant="danger"
@click="suspendProfile(report.reported.id as string)"
icon-left="delete"
size="small"
>{{ t("Suspend the profile") }}</o-button
>
<o-button
v-else-if="
(report.reported as IPerson).user &&
!((report.reported as IPerson).user as IUser).disabled
"
variant="danger"
@click="suspendUser((report.reported as IPerson).user as IUser)"
icon-left="delete"
size="small"
>{{ t("Suspend the account") }}</o-button
>
</td>
</tr>
<tr v-else>
<td>
{{ t("Reported identity") }}
</td>
<td>
{{ t("Unknown actor") }}
</td> </td>
</tr> </tr>
<tr> <tr>
<td>{{ t("Reported by") }}</td> <td>{{ t("Reported by") }}</td>
<td v-if="report.reporter.type === ActorType.APPLICATION"> <td v-if="report.reporter?.type === ActorType.APPLICATION">
{{ report.reporter.domain }} {{ report.reporter.domain }}
</td> </td>
<td v-else> <td v-else-if="report.reporter?.type === ActorType.PERSON">
<router-link <router-link
:to="{ :to="{
name: RouteName.ADMIN_PROFILE, name: RouteName.ADMIN_PROFILE,
@ -119,20 +156,23 @@
> >
<img <img
v-if="report.reporter.avatar" v-if="report.reporter.avatar"
class="image" class="image rounded-full"
:src="report.reporter.avatar.url" :src="report.reporter.avatar.url"
alt="" alt=""
/> />
{{ displayNameAndUsername(report.reporter) }} {{ displayNameAndUsername(report.reporter) }}
</router-link> </router-link>
</td> </td>
<td v-else>
{{ t("Unknown actor") }}
</td>
</tr> </tr>
<tr> <tr>
<td>{{ t("Reported") }}</td> <td>{{ t("Reported at") }}</td>
<td>{{ formatDateTimeString(report.insertedAt) }}</td> <td>{{ formatDateTimeString(report.insertedAt) }}</td>
</tr> </tr>
<tr v-if="report.updatedAt !== report.insertedAt"> <tr v-if="report.updatedAt !== report.insertedAt">
<td>{{ t("Updated") }}</td> <td>{{ t("Updated at") }}</td>
<td>{{ formatDateTimeString(report.updatedAt) }}</td> <td>{{ formatDateTimeString(report.updatedAt) }}</td>
</tr> </tr>
<tr> <tr>
@ -150,26 +190,6 @@
<span v-else>{{ t("Unknown") }}</span> <span v-else>{{ t("Unknown") }}</span>
</td> </td>
</tr> </tr>
<tr v-if="report.event && report.comments.length > 0">
<td>{{ t("Event") }}</td>
<td class="flex gap-2 items-center">
<router-link
class="underline"
:to="{
name: RouteName.EVENT,
params: { uuid: report.event.uuid },
}"
>
{{ report.event.title }}
</router-link>
<o-button
variant="danger"
@click="confirmEventDelete()"
icon-left="delete"
>{{ t("Delete") }}</o-button
>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</section> </section>
@ -178,7 +198,7 @@
<h2 class="mb-1">{{ t("Report reason") }}</h2> <h2 class="mb-1">{{ t("Report reason") }}</h2>
<div class=""> <div class="">
<div class="flex gap-1"> <div class="flex gap-1">
<figure class="" v-if="report.reported.avatar"> <figure class="" v-if="report.reported?.avatar">
<img <img
alt="" alt=""
:src="report.reported.avatar.url" :src="report.reported.avatar.url"
@ -188,12 +208,13 @@
/> />
</figure> </figure>
<AccountCircle v-else :size="36" /> <AccountCircle v-else :size="36" />
<div class=""> <div class="" v-if="report.reported">
<p class="" v-if="report.reported.name"> <p class="" v-if="report.reported?.name">
{{ report.reported.name }} {{ report.reported.name }}
</p> </p>
<p class="">@{{ usernameWithDomain(report.reported) }}</p> <p class="">@{{ usernameWithDomain(report.reported) }}</p>
</div> </div>
<p v-else>{{ t("Unknown actor") }}</p>
</div> </div>
<div <div
class="prose dark:prose-invert" class="prose dark:prose-invert"
@ -206,17 +227,27 @@
<section <section
class="bg-white dark:bg-zinc-700 rounded px-2 pt-1 pb-2 my-3" class="bg-white dark:bg-zinc-700 rounded px-2 pt-1 pb-2 my-3"
v-if="report.event && report.comments.length === 0" v-if="
report.events &&
report.events?.length > 0 &&
report.comments.length === 0
"
> >
<h2 class="mb-1">{{ t("Reported content") }}</h2> <h2 class="mb-1">{{ t("Reported content") }}</h2>
<EventCard :event="report.event" mode="row" class="my-2 max-w-4xl" /> <ul>
<o-button <li v-for="event in report.events" :key="event.id">
variant="danger" <EventCard :event="event" mode="row" class="my-2 max-w-4xl" />
@click="confirmEventDelete()" <o-button
icon-left="delete" variant="danger"
size="small" @click="confirmEventDelete(event)"
>{{ t("Delete") }}</o-button icon-left="delete"
> ><template v-if="isOnlyReportedContent">{{
t("Delete event and resolve report")
}}</template
><template v-else>{{ t("Delete event") }}</template></o-button
>
</li>
</ul>
</section> </section>
<section <section
@ -226,41 +257,55 @@
<h2 class="mb-1">{{ t("Reported content") }}</h2> <h2 class="mb-1">{{ t("Reported content") }}</h2>
<ul v-for="comment in report.comments" :key="comment.id"> <ul v-for="comment in report.comments" :key="comment.id">
<li> <li>
<div class="" v-if="comment"> <i18n-t keypath="Comment under event {eventTitle}" tag="p">
<article> <template #eventTitle>
<div class="flex gap-1"> <router-link
<figure class="" v-if="comment.actor?.avatar"> :to="{
<img name: RouteName.EVENT,
alt="" params: { uuid: comment.event?.uuid },
:src="comment.actor.avatar?.url" }"
class="rounded-full"
width="36"
height="36"
/>
</figure>
<AccountCircle v-else :size="36" />
<div>
<div v-if="comment.actor">
<p>{{ comment.actor.name }}</p>
<p>@{{ comment.actor.preferredUsername }}</p>
</div>
<span v-else>{{ t("Unknown actor") }}</span>
</div>
</div>
<div class="prose dark:prose-invert" v-html="comment.text" />
<o-button
variant="danger"
@click="confirmCommentDelete(comment)"
icon-left="delete"
size="small"
>{{ t("Delete") }}</o-button
> >
</article> <b>{{ comment.event?.title }}</b>
</div> </router-link>
</template>
</i18n-t>
<EventComment
:root-comment="true"
:comment="comment"
:event="comment.event as IEvent"
:current-actor="currentActor as IPerson"
:readOnly="true"
/>
<o-button
v-if="!comment.deletedAt"
variant="danger"
@click="confirmCommentDelete(comment)"
icon-left="delete"
><template v-if="isOnlyReportedContent">{{
t("Delete comment and resolve report")
}}</template
><template v-else>{{ t("Delete comment") }}</template></o-button
>
</li> </li>
</ul> </ul>
</section> </section>
<section
class="bg-white dark:bg-zinc-700 rounded px-2 pt-1 pb-2 my-3"
v-if="
report.events &&
report.events?.length === 0 &&
report.comments.length === 0
"
>
<EmptyContent inline center icon="alert-circle">
{{ t("No content found") }}
<template #desc>
{{ t("Maybe the content was removed by the author or a moderator") }}
</template>
</EmptyContent>
</section>
<section class="bg-white dark:bg-zinc-700 rounded px-2 pt-1 pb-2 my-3"> <section class="bg-white dark:bg-zinc-700 rounded px-2 pt-1 pb-2 my-3">
<h2 class="mb-1">{{ t("Notes") }}</h2> <h2 class="mb-1">{{ t("Notes") }}</h2>
<div <div
@ -315,7 +360,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { CREATE_REPORT_NOTE, REPORT, UPDATE_REPORT } from "@/graphql/report"; import { CREATE_REPORT_NOTE, REPORT, UPDATE_REPORT } from "@/graphql/report";
import { IReport, IReportNote } from "@/types/report.model"; import { IReport, IReportNote } from "@/types/report.model";
import { displayNameAndUsername, usernameWithDomain } from "@/types/actor"; import {
IPerson,
displayNameAndUsername,
usernameWithDomain,
} from "@/types/actor";
import { DELETE_EVENT } from "@/graphql/event"; import { DELETE_EVENT } from "@/graphql/event";
import uniq from "lodash/uniq"; import uniq from "lodash/uniq";
import { nl2br } from "@/utils/html"; import { nl2br } from "@/utils/html";
@ -325,7 +374,7 @@ import { ActorType, AntiSpamFeedback, ReportStatusEnum } from "@/types/enums";
import RouteName from "@/router/name"; import RouteName from "@/router/name";
import { GraphQLError } from "graphql"; import { GraphQLError } from "graphql";
import { ApolloCache, FetchResult } from "@apollo/client/core"; import { ApolloCache, FetchResult } from "@apollo/client/core";
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useLazyQuery, useMutation, useQuery } from "@vue/apollo-composable";
import { useCurrentActorClient } from "@/composition/apollo/actor"; import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useHead } from "@vueuse/head"; import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
@ -337,6 +386,13 @@ import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import EventCard from "@/components/Event/EventCard.vue"; import EventCard from "@/components/Event/EventCard.vue";
import { useFeatures } from "@/composition/apollo/config"; import { useFeatures } from "@/composition/apollo/config";
import { IEvent } from "@/types/event.model";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import EventComment from "@/components/Comment/EventComment.vue";
import { SUSPEND_PROFILE } from "@/graphql/actor";
import { GET_USER, SUSPEND_USER } from "@/graphql/user";
import { IUser } from "@/types/current-user.model";
import { waitApolloQuery } from "@/vue-apollo";
const router = useRouter(); const router = useRouter();
@ -374,6 +430,14 @@ const errors = ref<string[]>([]);
const noteContent = ref(""); const noteContent = ref("");
const reportedContent = computed(() => {
return [...(report.value?.events ?? []), ...(report.value?.comments ?? [])];
});
const isOnlyReportedContent = computed(
() => reportedContent.value.length === 1
);
const { const {
mutate: createReportNoteMutation, mutate: createReportNoteMutation,
onDone: createReportNoteMutationDone, onDone: createReportNoteMutationDone,
@ -419,26 +483,39 @@ createReportNoteMutationError((error) => {
const dialog = inject<Dialog>("dialog"); const dialog = inject<Dialog>("dialog");
const confirmEventDelete = (): void => { const addResolveReportPart = computed(() => {
if (isOnlyReportedContent.value) {
return "<p>" + t("This will also resolve the report.") + "</p>";
}
return "";
});
const confirmEventDelete = (event: IEvent): void => {
dialog?.confirm({ dialog?.confirm({
title: t("Deleting event"), title: t("Deleting event"),
message: t( message:
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead." t(
), "Are you sure you want to <b>delete</b> this event? <b>This action cannot be undone</b>. You may want to engage the discussion with the event creator and ask them to edit their event instead."
confirmText: t("Delete Event"), ) + addResolveReportPart.value,
confirmText: isOnlyReportedContent.value
? t("Delete event and resolve report")
: t("Delete event"),
variant: "danger", variant: "danger",
hasIcon: true, hasIcon: true,
onConfirm: () => deleteEvent(), onConfirm: () => deleteEvent(event),
}); });
}; };
const confirmCommentDelete = (comment: IComment): void => { const confirmCommentDelete = (comment: IComment): void => {
dialog?.confirm({ dialog?.confirm({
title: t("Deleting comment"), title: t("Deleting comment"),
message: t( message:
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone." t(
), "Are you sure you want to <b>delete</b> this comment? <b>This action cannot be undone</b>."
confirmText: t("Delete Comment"), ) + addResolveReportPart.value,
confirmText: isOnlyReportedContent.value
? t("Delete comment and resolve report")
: t("Delete comment"),
variant: "danger", variant: "danger",
hasIcon: true, hasIcon: true,
onConfirm: () => deleteCommentMutation({ commentId: comment.id }), onConfirm: () => deleteCommentMutation({ commentId: comment.id }),
@ -449,35 +526,105 @@ const {
mutate: deleteEventMutation, mutate: deleteEventMutation,
onDone: deleteEventMutationDone, onDone: deleteEventMutationDone,
onError: deleteEventMutationError, onError: deleteEventMutationError,
} = useMutation<{ deleteEvent: { id: string } }>(DELETE_EVENT); } = useMutation<{ deleteEvent: { id: string } }>(DELETE_EVENT, () => ({
update: (
store: ApolloCache<{ deleteEvent: { id: string } }>,
{ data }: FetchResult
) => {
if (data == null) return;
const reportCachedData = store.readQuery<{ report: IReport }>({
query: REPORT,
variables: { id: report.value?.id },
});
if (reportCachedData == null) return;
const { report: cachedReport } = reportCachedData;
if (cachedReport === null) {
console.error(
"Cannot update report events cache, because of null value."
);
return;
}
const updatedReport = {
...cachedReport,
events: cachedReport.events?.filter(
(cachedEvent) => cachedEvent.id !== data.deleteEvent.id
),
};
deleteEventMutationDone(() => { store.writeQuery({
const eventTitle = report.value?.event?.title; query: REPORT,
notifier?.success( variables: { id: report.value?.id },
t("Event {eventTitle} deleted", { data: { report: updatedReport },
eventTitle, });
}) },
); }));
deleteEventMutationDone(async () => {
if (reportedContent.value.length === 0) {
await updateReport(ReportStatusEnum.RESOLVED);
notifier?.success(t("Event deleted and report resolved"));
} else {
notifier?.success(t("Event deleted"));
}
}); });
deleteEventMutationError((error) => { deleteEventMutationError((error) => {
console.error(error); console.error(error);
}); });
const deleteEvent = async (): Promise<void> => { const deleteEvent = async (event: IEvent): Promise<void> => {
if (!report.value?.event?.id) return; if (!event?.id) return;
deleteEventMutation({ eventId: report.value.event.id }); deleteEventMutation(
{ eventId: event.id },
{ context: { eventTitle: event.title } }
);
}; };
const { const {
mutate: deleteCommentMutation, mutate: deleteCommentMutation,
onDone: deleteCommentMutationDone, onDone: deleteCommentMutationDone,
onError: deleteCommentMutationError, onError: deleteCommentMutationError,
} = useMutation<{ deleteComment: { id: string } }>(DELETE_COMMENT); } = useMutation<{ deleteComment: { id: string } }>(DELETE_COMMENT, () => ({
update: (
store: ApolloCache<{ deleteComment: { id: string } }>,
{ data }: FetchResult
) => {
if (data == null) return;
const reportCachedData = store.readQuery<{ report: IReport }>({
query: REPORT,
variables: { id: report.value?.id },
});
if (reportCachedData == null) return;
const { report: cachedReport } = reportCachedData;
if (cachedReport === null) {
console.error(
"Cannot update report comments cache, because of null value."
);
return;
}
const updatedReport = {
...cachedReport,
comments: cachedReport.comments.filter(
(cachedComment) => cachedComment.id !== data.deleteComment.id
),
};
deleteCommentMutationDone(() => { store.writeQuery({
notifier?.success(t("Comment deleted") as string); query: REPORT,
variables: { id: report.value?.id },
data: { report: updatedReport },
});
},
}));
deleteCommentMutationDone(async () => {
if (reportedContent.value.length === 0) {
await updateReport(ReportStatusEnum.RESOLVED);
notifier?.success(t("Comment deleted and report resolved"));
} else {
notifier?.success(t("Comment deleted"));
}
}); });
deleteCommentMutationError((error) => { deleteCommentMutationError((error) => {
@ -524,8 +671,8 @@ const {
}, },
})); }));
onUpdateReportMutation(() => { onUpdateReportMutation(async () => {
router.push({ name: RouteName.REPORTS }); await router.push({ name: RouteName.REPORTS });
}); });
onUpdateReportError((error) => { onUpdateReportError((error) => {
@ -561,6 +708,105 @@ const reportToAntispam = (spam: boolean) => {
}, },
}); });
}; };
const { mutate: doSuspendProfile, onDone: onSuspendProfileDone } = useMutation<
{
suspendProfile: { id: string };
},
{ id: string }
>(SUSPEND_PROFILE);
const { mutate: doSuspendUser, onDone: onSuspendUserDone } = useMutation<
{ suspendProfile: { id: string } },
{ userId: string }
>(SUSPEND_USER);
const userLazyQuery = useLazyQuery<{ user: IUser }, { id: string }>(GET_USER);
const suspendProfile = async (actorId: string): Promise<void> => {
dialog?.confirm({
title: t("Suspend the profile?"),
message:
t(
"Do you really want to suspend this profile? All of the profiles content will be deleted."
) +
`<p><b>` +
t("There will be no way to restore the profile's data!") +
`</b></p>`,
confirmText: t("Suspend the profile"),
cancelText: t("Cancel"),
variant: "danger",
onConfirm: async () => {
doSuspendProfile({
id: actorId,
});
return router.push({ name: RouteName.USERS });
},
});
};
const userSuspendedProfilesMessages = (user: IUser) => {
return (
t("The following user's profiles will be deleted, with all their data:") +
`<ul class="list-disc pl-3">` +
user.actors
.map((person) => `<li>${displayNameAndUsername(person)}</li>`)
.join("") +
`</ul><b>`
);
};
const cachedReportedUser = ref<IUser | undefined>();
const suspendUser = async (user: IUser): Promise<void> => {
try {
if (!cachedReportedUser.value) {
userLazyQuery.load(GET_USER, { id: user.id });
const userLazyQueryResult = await waitApolloQuery<
{ user: IUser },
{ id: string }
>(userLazyQuery);
console.debug("data", userLazyQueryResult);
cachedReportedUser.value = userLazyQueryResult.data.user;
}
dialog?.confirm({
title: t("Suspend the account?"),
message:
t("Do you really want to suspend the account « {emailAccount} » ?", {
emailAccount: cachedReportedUser.value.email,
}) +
" " +
userSuspendedProfilesMessages(cachedReportedUser.value) +
"<b>" +
t("There will be no way to restore the user's data!") +
`</b>`,
confirmText: t("Suspend the account"),
cancelText: t("Cancel"),
variant: "danger",
onConfirm: async () => {
doSuspendUser({
userId: user.id,
});
return router.push({ name: RouteName.USERS });
},
});
} catch (e) {
console.error(e);
}
};
onSuspendUserDone(async () => {
await router.push({ name: RouteName.REPORTS });
notifier?.success(t("User suspended and report resolved"));
});
onSuspendProfileDone(async () => {
await router.push({ name: RouteName.REPORTS });
notifier?.success(t("Profile suspended and report resolved"));
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
tbody td img.image, tbody td img.image,

View file

@ -1,7 +1,13 @@
import { ApolloClient, NormalizedCacheObject } from "@apollo/client/core"; import {
ApolloClient,
ApolloQueryResult,
NormalizedCacheObject,
OperationVariables,
} from "@apollo/client/core";
import buildCurrentUserResolver from "@/apollo/user"; import buildCurrentUserResolver from "@/apollo/user";
import { cache } from "./apollo/memory"; import { cache } from "./apollo/memory";
import { fullLink } from "./apollo/link"; import { fullLink } from "./apollo/link";
import { UseQueryReturn } from "@vue/apollo-composable";
export const apolloClient = new ApolloClient<NormalizedCacheObject>({ export const apolloClient = new ApolloClient<NormalizedCacheObject>({
cache, cache,
@ -9,3 +15,24 @@ export const apolloClient = new ApolloClient<NormalizedCacheObject>({
connectToDevTools: true, connectToDevTools: true,
resolvers: buildCurrentUserResolver(cache), resolvers: buildCurrentUserResolver(cache),
}); });
export function waitApolloQuery<
TResult = any,
TVariables extends OperationVariables = OperationVariables,
>({
onResult,
onError,
}: UseQueryReturn<TResult, TVariables>): Promise<ApolloQueryResult<TResult>> {
return new Promise((res, rej) => {
const { off: offResult } = onResult((result) => {
if (result.loading === false) {
offResult();
res(result);
}
});
const { off: offError } = onError((error) => {
offError();
rej(error);
});
});
}

View file

@ -2,7 +2,6 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Reports do
@moduledoc false @moduledoc false
alias Mobilizon.{Actors, Discussions, Events, Reports} alias Mobilizon.{Actors, Discussions, Events, Reports}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityStream alias Mobilizon.Federation.ActivityStream
alias Mobilizon.Federation.ActivityStream.Convertible alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Reports.Report alias Mobilizon.Reports.Report
@ -26,15 +25,19 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Reports do
%Actor{} = reported_actor = Actors.get_actor!(args.reported_id) %Actor{} = reported_actor = Actors.get_actor!(args.reported_id)
content = HTML.strip_tags(args.content) content = HTML.strip_tags(args.content)
event_id = Map.get(args, :event_id) events =
args
event = |> Map.get(:events_ids, [])
if is_nil(event_id) do |> Enum.map(fn event_id ->
nil case Events.get_event(event_id) do
else {:ok, event} -> event
{:ok, %Event{} = event} = Events.get_event(event_id) {:error, :event_not_found} -> nil
event end
end end)
|> Enum.filter(fn event ->
is_struct(event) and
Enum.member?([event.organizer_actor_id, event.attributed_to_id], reported_actor.id)
end)
comments = comments =
Discussions.list_comments_by_actor_and_ids( Discussions.list_comments_by_actor_and_ids(
@ -46,7 +49,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Reports do
reporter: reporter_actor, reporter: reporter_actor,
reported: reported_actor, reported: reported_actor,
content: content, content: content,
event: event, events: events,
comments: comments comments: comments
}) })
end end

View file

@ -9,11 +9,11 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
""" """
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions alias Mobilizon.Discussions.Comment
alias Mobilizon.Events
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Reports.Report alias Mobilizon.Reports.Report
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
alias Mobilizon.Federation.ActivityPub.Relay alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible} alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
@ -38,7 +38,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
"uri" => params["uri"], "uri" => params["uri"],
"content" => params["content"], "content" => params["content"],
"reported_id" => params["reported"].id, "reported_id" => params["reported"].id,
"event_id" => (!is_nil(params["event"]) && params["event"].id) || nil, "events" => params["events"],
"comments" => params["comments"] "comments" => params["comments"]
} }
end end
@ -50,9 +50,10 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
@impl Converter @impl Converter
@spec model_to_as(Report.t()) :: map @spec model_to_as(Report.t()) :: map
def model_to_as(%Report{} = report) do def model_to_as(%Report{} = report) do
object = [report.reported.url] ++ Enum.map(report.comments, fn comment -> comment.url end) object =
[report.reported.url] ++
object = if report.event, do: object ++ [report.event.url], else: object Enum.map(report.comments, fn comment -> comment.url end) ++
Enum.map(report.events, & &1.url)
%{ %{
"type" => "Flag", "type" => "Flag",
@ -68,14 +69,13 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
with {:ok, %Actor{} = reporter} <- with {:ok, %Actor{} = reporter} <-
ActivityPubActor.get_or_fetch_actor_by_url(object["actor"]), ActivityPubActor.get_or_fetch_actor_by_url(object["actor"]),
%Actor{} = reported <- find_reported(objects), %Actor{} = reported <- find_reported(objects),
event <- find_event(objects), %{events: events, comments: comments} <- find_events_and_comments(objects) do
comments <- find_comments(objects, reported, event) do
%{ %{
"reporter" => reporter, "reporter" => reporter,
"uri" => object["id"], "uri" => object["id"],
"content" => object["content"], "content" => object["content"],
"reported" => reported, "reported" => reported,
"event" => event, "events" => events,
"comments" => comments "comments" => comments
} }
end end
@ -94,26 +94,19 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
end) end)
end end
# Remove the reported actor and the event from the object list. defp find_events_and_comments(objects) do
@spec find_comments(list(String.t()), Actor.t() | nil, Event.t() | nil) :: list(Comment.t())
defp find_comments(objects, reported, event) do
objects objects
|> Enum.filter(fn url -> |> Enum.map(&ActivityPub.fetch_object_from_url/1)
!((!is_nil(reported) && url == reported.url) || (!is_nil(event) && event.url == url)) |> Enum.reduce(%{comments: [], events: []}, fn res, acc ->
end) case res do
|> Enum.map(&Discussions.get_comment_from_url/1) {:ok, %Event{} = event} ->
|> Enum.filter(& &1) Map.put(acc, :events, [event | acc.events])
end
@spec find_event(list(String.t())) :: Event.t() | nil {:ok, %Comment{} = comment} ->
defp find_event(objects) do Map.put(acc, :comments, [comment | acc.comments])
Enum.reduce_while(objects, nil, fn url, _ ->
case Events.get_event_by_url(url) do
%Event{} = event ->
{:halt, event}
_ -> _ ->
{:cont, nil} acc
end end
end) end)
end end

View file

@ -19,7 +19,7 @@ defmodule Mobilizon.GraphQL.Schema.ReportType do
field(:uri, :string, description: "The URI of the report", meta: [private: true]) field(:uri, :string, description: "The URI of the report", meta: [private: true])
field(:reported, :actor, description: "The actor that is being reported") field(:reported, :actor, description: "The actor that is being reported")
field(:reporter, :actor, description: "The actor that created the report") field(:reporter, :actor, description: "The actor that created the report")
field(:event, :event, description: "The event that is being reported") field(:events, list_of(:event), description: "The event that is being reported")
field(:comments, list_of(:comment), description: "The comments that are reported") field(:comments, list_of(:comment), description: "The comments that are reported")
field(:notes, list_of(:report_note), field(:notes, list_of(:report_note),
@ -100,11 +100,15 @@ defmodule Mobilizon.GraphQL.Schema.ReportType do
field :create_report, type: :report do field :create_report, type: :report do
arg(:content, :string, description: "The message sent with the report") arg(:content, :string, description: "The message sent with the report")
arg(:reported_id, non_null(:id), description: "The actor's ID that is being reported") arg(:reported_id, non_null(:id), description: "The actor's ID that is being reported")
arg(:event_id, :id, default_value: nil, description: "The event ID that is being reported")
arg(:events_ids, list_of(:id),
default_value: [],
description: "The list of event IDs that are being reported"
)
arg(:comments_ids, list_of(:id), arg(:comments_ids, list_of(:id),
default_value: [], default_value: [],
description: "The comment ID that is being reported" description: "The comment IDs that are being reported"
) )
arg(:forward, :boolean, arg(:forward, :boolean,

View file

@ -22,13 +22,13 @@ defmodule Mobilizon.Reports.Report do
reported: Actor.t(), reported: Actor.t(),
reporter: Actor.t(), reporter: Actor.t(),
manager: Actor.t(), manager: Actor.t(),
event: Event.t(), events: [Event.t()],
comments: [Comment.t()], comments: [Comment.t()],
notes: [Note.t()] notes: [Note.t()]
} }
@required_attrs [:url, :reported_id, :reporter_id] @required_attrs [:url, :reported_id, :reporter_id]
@optional_attrs [:content, :status, :manager_id, :event_id, :local] @optional_attrs [:content, :status, :manager_id, :local]
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@timestamps_opts [type: :utc_datetime] @timestamps_opts [type: :utc_datetime]
@ -46,8 +46,8 @@ defmodule Mobilizon.Reports.Report do
belongs_to(:reporter, Actor) belongs_to(:reporter, Actor)
# The actor who last acted on this report # The actor who last acted on this report
belongs_to(:manager, Actor) belongs_to(:manager, Actor)
# The eventual Event inside the report # The eventual Events inside the report
belongs_to(:event, Event) many_to_many(:events, Event, join_through: "reports_events", on_replace: :delete)
# The eventual Comments inside the report # The eventual Comments inside the report
many_to_many(:comments, Comment, join_through: "reports_comments", on_replace: :delete) many_to_many(:comments, Comment, join_through: "reports_comments", on_replace: :delete)
# The notes associated to the report # The notes associated to the report
@ -62,6 +62,7 @@ defmodule Mobilizon.Reports.Report do
report report
|> cast(attrs, @attrs) |> cast(attrs, @attrs)
|> maybe_generate_url() |> maybe_generate_url()
|> maybe_put_events(attrs)
|> maybe_put_comments(attrs) |> maybe_put_comments(attrs)
|> validate_required(@required_attrs) |> validate_required(@required_attrs)
end end
@ -72,6 +73,12 @@ defmodule Mobilizon.Reports.Report do
defp maybe_put_comments(%Ecto.Changeset{} = changeset, _), do: changeset defp maybe_put_comments(%Ecto.Changeset{} = changeset, _), do: changeset
defp maybe_put_events(%Ecto.Changeset{} = changeset, %{events: events}) do
put_assoc(changeset, :events, events)
end
defp maybe_put_events(%Ecto.Changeset{} = changeset, _), do: changeset
@spec maybe_generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t() @spec maybe_generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp maybe_generate_url(%Ecto.Changeset{} = changeset) do defp maybe_generate_url(%Ecto.Changeset{} = changeset) do
with res when res in [:error, {:data, nil}] <- fetch_field(changeset, :url), with res when res in [:error, {:data, nil}] <- fetch_field(changeset, :url),

View file

@ -21,7 +21,7 @@ defmodule Mobilizon.Reports do
def get_report(id) do def get_report(id) do
Report Report
|> Repo.get(id) |> Repo.get(id)
|> Repo.preload([:reported, :reporter, :manager, :event, :comments, :notes]) |> Repo.preload([:reported, :reporter, :manager, :events, :comments, :notes])
end end
@doc """ @doc """
@ -33,7 +33,7 @@ defmodule Mobilizon.Reports do
%Report{} %Report{}
|> Report.changeset(attrs) |> Report.changeset(attrs)
|> Repo.insert() do |> Repo.insert() do
{:ok, Repo.preload(report, [:event, :reported, :reporter, :comments])} {:ok, Repo.preload(report, [:events, :reported, :reporter, :comments])}
end end
end end
@ -102,7 +102,7 @@ defmodule Mobilizon.Reports do
@spec list_reports_query(atom()) :: Ecto.Query.t() @spec list_reports_query(atom()) :: Ecto.Query.t()
defp list_reports_query(status) do defp list_reports_query(status) do
Report Report
|> preload([:reported, :reporter, :manager, :event, :comments, :notes]) |> preload([:reported, :reporter, :manager, :events, :comments, :notes])
|> where([r], r.status == ^status) |> where([r], r.status == ^status)
end end

View file

@ -0,0 +1,70 @@
defmodule Mobilizon.Storage.Views.Instances do
@moduledoc """
SQL code for PostgreSQL instances materialized view
"""
def create_materialized_view do
"""
CREATE MATERIALIZED VIEW instances AS
SELECT
a.domain,
COUNT(DISTINCT(p.id)) AS person_count,
COUNT(DISTINCT(g.id)) AS group_count,
COUNT(DISTINCT(e.id)) AS event_count,
COUNT(f1.id) AS followers_count,
COUNT(f2.id) AS followings_count,
COUNT(r.id) AS reports_count,
SUM(COALESCE((m.file->>'size')::int, 0)) AS media_size
FROM actors a
LEFT JOIN actors p ON a.id = p.id AND p.type = 'Person'
LEFT JOIN actors g ON a.id = g.id AND g.type = 'Group'
LEFT JOIN events e ON a.id = e.organizer_actor_id
LEFT JOIN followers f1 ON a.id = f1.actor_id
LEFT JOIN followers f2 ON a.id = f2.target_actor_id
LEFT JOIN reports r ON r.reported_id = a.id
LEFT JOIN medias m ON m.actor_id = a.id
WHERE a.domain IS NOT NULL
GROUP BY a.domain;
"""
end
def refresh_instances do
"""
CREATE OR REPLACE FUNCTION refresh_instances()
RETURNS trigger AS $$
BEGIN
REFRESH MATERIALIZED VIEW instances;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
end
def drop_trigger do
"""
DROP TRIGGER IF EXISTS refresh_instances_trigger ON actors;
"""
end
def create_trigger do
"""
CREATE TRIGGER refresh_instances_trigger
AFTER INSERT OR UPDATE OR DELETE
ON actors
FOR EACH STATEMENT
EXECUTE PROCEDURE refresh_instances();
"""
end
def drop_refresh_instances do
"""
DROP FUNCTION IF EXISTS refresh_instances() CASCADE;
"""
end
def drop_view do
"""
DROP MATERIALIZED VIEW IF EXISTS instances;
"""
end
end

View file

@ -139,7 +139,7 @@ defmodule Mobilizon.Service.AntiSpam.Akismet do
end end
end end
defp report_to_akismet_comment(%Report{event: %Event{id: event_id}}) do defp report_to_akismet_comment(%Report{events: [%Event{id: event_id} | _]}) do
with %Event{description: body, organizer_actor: %Actor{} = actor} <- with %Event{description: body, organizer_actor: %Actor{} = actor} <-
Events.get_event_with_preload!(event_id), Events.get_event_with_preload!(event_id),
{email, preferred_username, ip} <- actor_details(actor) do {email, preferred_username, ip} <- actor_details(actor) do

View file

@ -104,7 +104,7 @@
</td> </td>
</tr> </tr>
<% end %> <% end %>
<%= if Map.has_key?(@report, :event) and @report.event do %> <%= if Map.has_key?(@report, :events) and length(@report.events) > 0 do %>
<tr> <tr>
<td <td
bgcolor="#ffffff" bgcolor="#ffffff"
@ -112,16 +112,19 @@
style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;"
> >
<p style="margin: 0;"> <p style="margin: 0;">
<h3><%= gettext("Event") %></h3> <h3><%= gettext("Flagged events") %></h3>
<a <%= for event <- @report.events do %>
href={"#{"#{Mobilizon.Web.Endpoint.url()}/events/#{@report.event.uuid}"}"} <a
target="_blank" href={"#{"#{Mobilizon.Web.Endpoint.url()}/events/#{event.uuid}"}"}
> target="_blank"
<%= gettext("%{title} by %{creator}", >
title: @report.event.title, <%= gettext("%{title} by %{creator}",
creator: Mobilizon.Actors.Actor.preferred_username_and_domain(@report.reported) title: event.title,
) %> creator:
</a> Mobilizon.Actors.Actor.preferred_username_and_domain(@report.reported)
) %>
</a>
<% end %>
</p> </p>
<table <table
cellspacing="0" cellspacing="0"

View file

@ -7,9 +7,11 @@
<%= gettext "Profile %{profile} was reported", profile: Mobilizon.Actors.Actor.display_name_and_username(@report.reported) %> <%= gettext "Profile %{profile} was reported", profile: Mobilizon.Actors.Actor.display_name_and_username(@report.reported) %>
<% end %> <% end %>
<% end %> <% end %>
<%= if Map.has_key?(@report, :event) and @report.event do %> <%= if Map.has_key?(@report, :event) && length(@report.events) > 0 do %>
<%= gettext "Event" %> <%= gettext "Events" %>
<%= @report.event.title %> <%= for event <- @report.events do %>
<%= event.title %>
<% end %>
<% end %> <% end %>
<%= if Map.has_key?(@report, :comments) && length(@report.comments) > 0 do %> <%= if Map.has_key?(@report, :comments) && length(@report.comments) > 0 do %>
<%= gettext "Comments" %> <%= gettext "Comments" %>

View file

@ -1,51 +1,15 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddInstanceMaterializedView do defmodule Mobilizon.Storage.Repo.Migrations.AddInstanceMaterializedView do
use Ecto.Migration use Ecto.Migration
alias Mobilizon.Storage.Views.Instances
def up do def up do
execute(""" execute(Instances.create_materialized_view())
CREATE MATERIALIZED VIEW instances AS
SELECT
a.domain,
COUNT(DISTINCT(p.id)) AS person_count,
COUNT(DISTINCT(g.id)) AS group_count,
COUNT(DISTINCT(e.id)) AS event_count,
COUNT(f1.id) AS followers_count,
COUNT(f2.id) AS followings_count,
COUNT(r.id) AS reports_count,
SUM(COALESCE((m.file->>'size')::int, 0)) AS media_size
FROM actors a
LEFT JOIN actors p ON a.id = p.id AND p.type = 'Person'
LEFT JOIN actors g ON a.id = g.id AND g.type = 'Group'
LEFT JOIN events e ON a.id = e.organizer_actor_id
LEFT JOIN followers f1 ON a.id = f1.actor_id
LEFT JOIN followers f2 ON a.id = f2.target_actor_id
LEFT JOIN reports r ON r.reported_id = a.id
LEFT JOIN medias m ON m.actor_id = a.id
WHERE a.domain IS NOT NULL
GROUP BY a.domain;
""")
execute(""" execute(Instances.refresh_instances())
CREATE OR REPLACE FUNCTION refresh_instances()
RETURNS trigger AS $$
BEGIN
REFRESH MATERIALIZED VIEW instances;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
""")
execute(""" execute(Instances.drop_trigger())
DROP TRIGGER IF EXISTS refresh_instances_trigger ON actors;
""")
execute(""" execute(Instances.create_trigger())
CREATE TRIGGER refresh_instances_trigger
AFTER INSERT OR UPDATE OR DELETE
ON actors
FOR EACH STATEMENT
EXECUTE PROCEDURE refresh_instances();
""")
create_if_not_exists(unique_index("instances", [:domain])) create_if_not_exists(unique_index("instances", [:domain]))
end end
@ -53,12 +17,8 @@ defmodule Mobilizon.Storage.Repo.Migrations.AddInstanceMaterializedView do
def down do def down do
drop_if_exists(unique_index("instances", [:domain])) drop_if_exists(unique_index("instances", [:domain]))
execute(""" execute(Instances.drop_refresh_instances())
DROP FUNCTION IF EXISTS refresh_instances() CASCADE;
""")
execute(""" execute(Instances.drop_view())
DROP MATERIALIZED VIEW IF EXISTS instances;
""")
end end
end end

View file

@ -0,0 +1,14 @@
defmodule Mobilizon.Storage.Repo.Migrations.AllowMultipleEventsToBeReported do
use Ecto.Migration
def up do
create table(:reports_events, primary_key: false) do
add(:report_id, references(:reports, on_delete: :delete_all), null: false)
add(:event_id, references(:events, on_delete: :delete_all), null: false)
end
end
def down do
drop table(:reports_events)
end
end

View file

@ -0,0 +1,31 @@
defmodule Mobilizon.Storage.Repo.Migrations.BackfillReportEventsWithOldEvents do
use Ecto.Migration
def up do
process_reports_with_events()
end
def down do
IO.puts("Doing nothing, migration can't be reverted")
end
defp process_reports_with_events do
%Postgrex.Result{rows: rows} =
Ecto.Adapters.SQL.query!(
Mobilizon.Storage.Repo,
"SELECT id, event_id FROM reports WHERE event_id IS NOT NULL"
)
Enum.map(rows, &migrate_event_row/1)
end
defp migrate_event_row([report_id, event_id]) when not is_nil(event_id) do
Ecto.Adapters.SQL.query!(
Mobilizon.Storage.Repo,
"INSERT INTO reports_events VALUES ($1, $2)",
[report_id, event_id]
)
end
defp migrate_event_row(_), do: :ok
end

View file

@ -0,0 +1,15 @@
defmodule Mobilizon.Storage.Repo.Migrations.RemoveObsoleteEventIdOnReports do
use Ecto.Migration
def up do
alter table(:reports) do
remove_if_exists :event_id, :integer
end
end
def down do
alter table(:reports) do
add(:event_id, references(:events, on_delete: :delete_all), null: true)
end
end
end

View file

@ -0,0 +1,48 @@
defmodule Mobilizon.Storage.Repo.Migrations.RemoveOnDeleteCascadeForReports do
use Ecto.Migration
alias Mobilizon.Storage.Views.Instances
def up do
execute(Instances.drop_view())
drop(constraint(:reports, "reports_reported_id_fkey"))
drop(constraint(:reports, "reports_reporter_id_fkey"))
drop(constraint(:reports, "reports_manager_id_fkey"))
alter table(:reports) do
modify(:reported_id, references(:actors, on_delete: :nilify_all), null: true)
modify(:reporter_id, references(:actors, on_delete: :nilify_all), null: true)
modify(:manager_id, references(:actors, on_delete: :nilify_all), null: true)
end
drop(constraint(:report_notes, "report_notes_moderator_id_fkey"))
alter table(:report_notes) do
modify(:moderator_id, references(:actors, on_delete: :nilify_all), null: true)
end
execute(Instances.create_materialized_view())
end
def down do
execute(Instances.drop_view())
drop(constraint(:reports, "reports_reported_id_fkey"))
drop(constraint(:reports, "reports_reporter_id_fkey"))
drop(constraint(:reports, "reports_manager_id_fkey"))
alter table(:reports) do
modify(:reported_id, references(:actors, on_delete: :delete_all), null: false)
modify(:reporter_id, references(:actors, on_delete: :delete_all), null: false)
modify(:manager_id, references(:actors, on_delete: :delete_all), null: true)
end
drop(constraint(:report_notes, "report_notes_moderator_id_fkey"))
alter table(:report_notes) do
modify(:moderator_id, references(:actors, on_delete: :delete_all), null: false)
end
execute(Instances.create_materialized_view())
end
end

View file

@ -30,7 +30,7 @@ defmodule Mobilizon.GraphQL.API.ReportTest do
reporter_id: reporter_id, reporter_id: reporter_id,
reported_id: reported_id, reported_id: reported_id,
content: comment, content: comment,
event_id: event_id, events_ids: [event_id],
comments_ids: [], comments_ids: [],
forward: false forward: false
}) })
@ -64,7 +64,7 @@ defmodule Mobilizon.GraphQL.API.ReportTest do
reporter_id: reporter_id, reporter_id: reporter_id,
reported_id: reported_id, reported_id: reported_id,
content: comment, content: comment,
event_id: nil, events_ids: [],
comments_ids: [comment_1_id, comment_2_id] comments_ids: [comment_1_id, comment_2_id]
}) })
@ -100,7 +100,7 @@ defmodule Mobilizon.GraphQL.API.ReportTest do
reporter_id: reporter_id, reporter_id: reporter_id,
reported_id: reported_id, reported_id: reported_id,
content: comment, content: comment,
event_id: nil, events_ids: [],
comments_ids: [comment_1_id, comment_2_id], comments_ids: [comment_1_id, comment_2_id],
forward: true forward: true
}) })
@ -131,7 +131,7 @@ defmodule Mobilizon.GraphQL.API.ReportTest do
reporter_id: reporter_id, reporter_id: reporter_id,
reported_id: reported_id, reported_id: reported_id,
content: "This is not a nice thing", content: "This is not a nice thing",
event_id: nil, events_ids: [],
comments_ids: [comment_1_id], comments_ids: [comment_1_id],
forward: true forward: true
}) })
@ -157,7 +157,7 @@ defmodule Mobilizon.GraphQL.API.ReportTest do
reporter_id: reporter_id, reporter_id: reporter_id,
reported_id: reported_id, reported_id: reported_id,
content: "This is not a nice thing", content: "This is not a nice thing",
event_id: nil, events_ids: [],
comments_ids: [comment_1_id], comments_ids: [comment_1_id],
forward: true forward: true
}) })

View file

@ -15,17 +15,17 @@ defmodule Mobilizon.GraphQL.Resolvers.ReportTest do
describe "Resolver: Report a content" do describe "Resolver: Report a content" do
@create_report_mutation """ @create_report_mutation """
mutation CreateReport($reportedId: ID!, $eventId: ID, $content: String) { mutation CreateReport($reportedId: ID!, $eventsIds: [ID], $content: String) {
createReport( createReport(
reportedId: $reportedId, reportedId: $reportedId,
eventId: $eventId, eventsIds: $eventsIds,
content: $content content: $content
) { ) {
content, content,
reporter { reporter {
id id
}, },
event { events {
id id
}, },
status status
@ -55,7 +55,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ReportTest do
query: @create_report_mutation, query: @create_report_mutation,
variables: %{ variables: %{
reportedId: reported.id, reportedId: reported.id,
eventId: event.id, eventsIds: [event.id],
content: "This is an issue" content: "This is an issue"
} }
) )
@ -63,7 +63,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ReportTest do
assert res["errors"] == nil assert res["errors"] == nil
assert res["data"]["createReport"]["content"] == "This is an issue" assert res["data"]["createReport"]["content"] == "This is an issue"
assert res["data"]["createReport"]["status"] == "OPEN" assert res["data"]["createReport"]["status"] == "OPEN"
assert res["data"]["createReport"]["event"]["id"] == to_string(event.id) assert res["data"]["createReport"]["events"] |> hd |> Map.get("id") == to_string(event.id)
assert res["data"]["createReport"]["reporter"]["id"] == assert res["data"]["createReport"]["reporter"]["id"] ==
to_string(reporter.id) to_string(reporter.id)
@ -122,7 +122,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ReportTest do
reporter { reporter {
id id
}, },
event { events {
id id
}, },
status status
@ -280,7 +280,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ReportTest do
reporter { reporter {
preferredUsername preferredUsername
}, },
event { events {
title title
}, },
comments { comments {
@ -312,7 +312,9 @@ defmodule Mobilizon.GraphQL.Resolvers.ReportTest do
reporter.preferred_username reporter.preferred_username
assert json_response(res, 200)["data"]["report"]["content"] == report.content assert json_response(res, 200)["data"]["report"]["content"] == report.content
assert json_response(res, 200)["data"]["report"]["event"]["title"] == report.event.title
assert json_response(res, 200)["data"]["report"]["events"] |> hd |> Map.get("title") ==
report.events |> hd |> Map.get(:title)
assert json_response(res, 200)["data"]["report"]["comments"] |> hd |> Map.get("text") == assert json_response(res, 200)["data"]["report"]["comments"] |> hd |> Map.get("text") ==
report.comments |> hd |> Map.get(:text) report.comments |> hd |> Map.get(:text)

View file

@ -322,7 +322,7 @@ defmodule Mobilizon.Factory do
url: "http://mobilizon.test/report/deae1020-54b8-47df-9eea-d8c0e943e57f/activity", url: "http://mobilizon.test/report/deae1020-54b8-47df-9eea-d8c0e943e57f/activity",
reported: build(:actor), reported: build(:actor),
reporter: build(:actor), reporter: build(:actor),
event: build(:event), events: build_list(1, :event),
comments: build_list(1, :comment) comments: build_list(1, :comment)
} }
end end