Add admin dashboard, event reporting, moderation report screens, moderation log

Close #156 and #158

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-09-09 09:31:08 +02:00
parent 164429964a
commit 27f2597b07
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
77 changed files with 1682 additions and 201 deletions

View File

@ -11,7 +11,7 @@
<script lang="ts"> <script lang="ts">
import NavBar from '@/components/NavBar.vue'; import NavBar from '@/components/NavBar.vue';
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import { AUTH_ACCESS_TOKEN, AUTH_USER_ACTOR, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants'; import { AUTH_ACCESS_TOKEN, AUTH_USER_ACTOR, AUTH_USER_EMAIL, AUTH_USER_ID, AUTH_USER_ROLE } from '@/constants';
import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user'; import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user';
import { ICurrentUser } from '@/types/current-user.model'; import { ICurrentUser } from '@/types/current-user.model';
import Footer from '@/components/Footer.vue'; import Footer from '@/components/Footer.vue';
@ -46,14 +46,16 @@ export default class App extends Vue {
const userId = localStorage.getItem(AUTH_USER_ID); const userId = localStorage.getItem(AUTH_USER_ID);
const userEmail = localStorage.getItem(AUTH_USER_EMAIL); const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN); const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
const role = localStorage.getItem(AUTH_USER_ROLE);
if (userId && userEmail && accessToken) { if (userId && userEmail && accessToken && role) {
return this.$apollo.mutate({ return this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT, mutation: UPDATE_CURRENT_USER_CLIENT,
variables: { variables: {
id: userId, id: userId,
email: userEmail, email: userEmail,
isLoggedIn: true, isLoggedIn: true,
role,
}, },
}); });
} }
@ -74,6 +76,7 @@ export default class App extends Vue {
@import "~bulma/sass/components/navbar.sass"; @import "~bulma/sass/components/navbar.sass";
@import "~bulma/sass/components/pagination.sass"; @import "~bulma/sass/components/pagination.sass";
@import "~bulma/sass/components/dropdown.sass"; @import "~bulma/sass/components/dropdown.sass";
@import "~bulma/sass/components/breadcrumb.sass";
@import "~bulma/sass/elements/box.sass"; @import "~bulma/sass/elements/box.sass";
@import "~bulma/sass/elements/button.sass"; @import "~bulma/sass/elements/button.sass";
@import "~bulma/sass/elements/container.sass"; @import "~bulma/sass/elements/container.sass";
@ -84,6 +87,7 @@ export default class App extends Vue {
@import "~bulma/sass/elements/tag.sass"; @import "~bulma/sass/elements/tag.sass";
@import "~bulma/sass/elements/title.sass"; @import "~bulma/sass/elements/title.sass";
@import "~bulma/sass/elements/notification"; @import "~bulma/sass/elements/notification";
@import "~bulma/sass/elements/table";
@import "~bulma/sass/grid/_all.sass"; @import "~bulma/sass/grid/_all.sass";
@import "~bulma/sass/layout/_all.sass"; @import "~bulma/sass/layout/_all.sass";
@ -100,6 +104,7 @@ export default class App extends Vue {
@import "~buefy/src/scss/components/upload"; @import "~buefy/src/scss/components/upload";
@import "~buefy/src/scss/components/radio"; @import "~buefy/src/scss/components/radio";
@import "~buefy/src/scss/components/switch"; @import "~buefy/src/scss/components/switch";
@import "~buefy/src/scss/components/table";
.router-enter-active, .router-enter-active,
.router-leave-active { .router-leave-active {

View File

@ -1,5 +1,6 @@
import { ApolloCache } from 'apollo-cache'; import { ApolloCache } from 'apollo-cache';
import { NormalizedCacheObject } from 'apollo-cache-inmemory'; import { NormalizedCacheObject } from 'apollo-cache-inmemory';
import { ICurrentUserRole } from '@/types/current-user.model';
export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObject>) { export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObject>) {
cache.writeData({ cache.writeData({
@ -9,18 +10,20 @@ export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObjec
id: null, id: null,
email: null, email: null,
isLoggedIn: false, isLoggedIn: false,
role: ICurrentUserRole.USER,
}, },
}, },
}); });
return { return {
Mutation: { Mutation: {
updateCurrentUser: (_, { id, email, isLoggedIn }, { cache }) => { updateCurrentUser: (_, { id, email, isLoggedIn, role }, { cache }) => {
const data = { const data = {
currentUser: { currentUser: {
id, id,
email, email,
isLoggedIn, isLoggedIn,
role,
__typename: 'CurrentUser', __typename: 'CurrentUser',
}, },
}; };

View File

@ -5,7 +5,7 @@
<div class="tag-container" v-if="event.tags"> <div class="tag-container" v-if="event.tags">
<b-tag v-for="tag in event.tags.slice(0, 3)" :key="tag.slug" type="is-secondary">{{ tag.title }}</b-tag> <b-tag v-for="tag in event.tags.slice(0, 3)" :key="tag.slug" type="is-secondary">{{ tag.title }}</b-tag>
</div> </div>
<img src="https://picsum.photos/g/400/225/?random"> <img src="https://picsum.photos/g/400/225/?random" />
</figure> </figure>
</div> </div>
<div class="content"> <div class="content">

View File

@ -1,8 +1,5 @@
<template> <template>
<translate <span v-if="!endsOn">{{ beginsOn | formatDateTimeString }}</span>
v-if="!endsOn"
:translate-params="{date: formatDate(beginsOn), time: formatTime(beginsOn)}"
>The %{ date } at %{ time }</translate>
<translate <translate
v-else-if="isSameDay()" v-else-if="isSameDay()"
:translate-params="{date: formatDate(beginsOn), startTime: formatTime(beginsOn), endTime: formatTime(endsOn)}" :translate-params="{date: formatDate(beginsOn), startTime: formatTime(beginsOn), endTime: formatTime(endsOn)}"
@ -21,11 +18,13 @@ export default class EventFullDate extends Vue {
@Prop({ required: false }) endsOn!: string; @Prop({ required: false }) endsOn!: string;
formatDate(value) { formatDate(value) {
return value ? new Date(value).toLocaleString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) : null; if (!this.$options.filters) return;
return this.$options.filters.formatDateString(value);
} }
formatTime(value) { formatTime(value) {
return value ? new Date(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' }) : null; if (!this.$options.filters) return;
return this.$options.filters.formatTimeString(value);
} }
isSameDay() { isSameDay() {

View File

@ -44,6 +44,10 @@
<router-link :to="{ name: ActorRouteName.CREATE_GROUP }" v-translate>Create group</router-link> <router-link :to="{ name: ActorRouteName.CREATE_GROUP }" v-translate>Create group</router-link>
</span> </span>
<span class="navbar-item" v-if="currentUser.role === ICurrentUserRole.ADMINISTRATOR">
<router-link :to="{ name: AdminRouteName.DASHBOARD }" v-translate>Administration</router-link>
</span>
<a v-translate class="navbar-item" v-on:click="logout()">Log out</a> <a v-translate class="navbar-item" v-on:click="logout()">Log out</a>
</div> </div>
</div> </div>
@ -71,10 +75,12 @@ import { LOGGED_PERSON } from '@/graphql/actor';
import { IPerson } from '@/types/actor'; import { IPerson } from '@/types/actor';
import { CONFIG } from '@/graphql/config'; import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model'; import { IConfig } from '@/types/config.model';
import { ICurrentUser } from '@/types/current-user.model'; import { ICurrentUser, ICurrentUserRole } from '@/types/current-user.model';
import Logo from '@/components/Logo.vue'; import Logo from '@/components/Logo.vue';
import SearchField from '@/components/SearchField.vue'; import SearchField from '@/components/SearchField.vue';
import { ActorRouteName } from '@/router/actor'; import { ActorRouteName } from '@/router/actor';
import { AdminRouteName } from '@/router/admin';
import { RouteName } from '@/router';
@Component({ @Component({
apollo: { apollo: {
@ -98,9 +104,11 @@ export default class NavBar extends Vue {
loggedPerson: IPerson | null = null; loggedPerson: IPerson | null = null;
config!: IConfig; config!: IConfig;
currentUser!: ICurrentUser; currentUser!: ICurrentUser;
ICurrentUserRole = ICurrentUserRole;
showNavbar: boolean = false; showNavbar: boolean = false;
ActorRouteName = ActorRouteName; ActorRouteName = ActorRouteName;
AdminRouteName = AdminRouteName;
@Watch('currentUser') @Watch('currentUser')
async onCurrentUserChanged() { async onCurrentUserChanged() {
@ -119,7 +127,8 @@ export default class NavBar extends Vue {
async logout() { async logout() {
await logout(this.$apollo.provider.defaultClient); await logout(this.$apollo.provider.defaultClient);
return this.$router.push({ path: '/' }); if (this.$route.name === RouteName.HOME) return;
return this.$router.push({ name: RouteName.HOME });
} }
} }
</script> </script>

View File

@ -0,0 +1,45 @@
<template>
<div class="card" v-if="report">
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="report.reported.avatar">
<img :src="report.reported.avatar.url" />
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{ report.reported.name }}</p>
<p class="subtitle is-6">@{{ report.reported.preferredUsername }}</p>
</div>
</div>
<div class="content columns">
<div class="column is-one-quarter box">Reported by <img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}</div>
<div class="column box" v-if="report.event">
<img class="image" v-if="report.event.picture" :src="report.event.picture.url" />
<span>{{ report.event.title }}</span>
</div>
<div class="column box" v-if="report.reportContent">{{ report.reportContent }}</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IReport } from '@/types/report.model';
import { EventRouteName } from '@/router/event';
@Component
export default class ReportCard extends Vue {
@Prop({ required: true }) report!: IReport;
EventRouteName = EventRouteName;
}
</script>
<style lang="scss">
.content img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
</style>

View File

@ -0,0 +1,101 @@
<template>
<div class="modal-card">
<header class="modal-card-head" v-if="title">
<p class="modal-card-title">{{ title }}</p>
</header>
<section
class="modal-card-body is-flex"
:class="{ 'is-titleless': !title }">
<div class="media">
<div
class="media-left">
<b-icon
icon="alert"
type="is-warning"
size="is-large"/>
</div>
<div class="media-content">
<p>The report will be sent to the moderators of your instance.
You can explain why you report this content below.</p>
<div class="control">
<b-input
v-model="content"
type="textarea"
@keyup.enter="confirm"
placeholder="Additional comments"
/>
</div>
<p v-if="outsideDomain">
The content came from another server. Transfer an anonymous copy of the report ?
</p>
<div class="control" v-if="outsideDomain">
<b-switch v-model="forward">Transfer to {{ outsideDomain }}</b-switch>
</div>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button
class="button"
ref="cancelButton"
@click="cancel('button')">
{{ cancelText }}
</button>
<button
class="button is-primary"
ref="confirmButton"
@click="confirm">
{{ confirmText }}
</button>
</footer>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { removeElement } from 'buefy/src/utils/helpers';
@Component({
mounted() {
this.$data.isActive = true;
},
})
export default class ReportModal extends Vue {
@Prop({ type: Function, default: () => {} }) onConfirm;
@Prop({ type: String }) title;
@Prop({ type: String, default: '' }) outsideDomain;
@Prop({ type: String, default: 'Cancel' }) cancelText;
@Prop({ type: String, default: 'Send the report' }) confirmText;
isActive: boolean = false;
content: string = '';
forward: boolean = false;
confirm() {
this.onConfirm(this.content, this.forward);
this.close();
}
/**
* Close the Dialog.
*/
close() {
this.isActive = false;
// Timeout for the animation complete before destroying
setTimeout(() => {
this.$destroy();
removeElement(this.$el);
}, 150);
}
}
</script>
<style lang="scss">
.modal-card .modal-card-foot {
justify-content: flex-end;
}
</style>

View File

@ -3,3 +3,4 @@ export const AUTH_REFRESH_TOKEN = 'auth-refresh-token';
export const AUTH_USER_ID = 'auth-user-id'; export const AUTH_USER_ID = 'auth-user-id';
export const AUTH_USER_EMAIL = 'auth-user-email'; export const AUTH_USER_EMAIL = 'auth-user-email';
export const AUTH_USER_ACTOR = 'auth-user-actor'; export const AUTH_USER_ACTOR = 'auth-user-actor';
export const AUTH_USER_ROLE = 'auth-user-role';

View File

@ -0,0 +1,19 @@
function parseDateTime(value: string): Date {
return new Date(value);
}
function formatDateString(value: string): string {
return parseDateTime(value).toLocaleString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
function formatTimeString(value: string): string {
return parseDateTime(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' });
}
function formatDateTimeString(value: string): string {
return parseDateTime(value).toLocaleTimeString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
}
export { formatDateString, formatTimeString, formatDateTimeString };

9
js/src/filters/index.ts Normal file
View File

@ -0,0 +1,9 @@
import { formatDateString, formatTimeString, formatDateTimeString } from './datetime';
export default {
install(vue) {
vue.filter('formatDateString', formatDateString);
vue.filter('formatTimeString', formatTimeString);
vue.filter('formatDateTimeString', formatDateTimeString);
},
};

View File

@ -177,7 +177,7 @@ query($name:String!) {
export const CREATE_GROUP = gql` export const CREATE_GROUP = gql`
mutation CreateGroup( mutation CreateGroup(
$creatorActorId: Int!, $creatorActorId: ID!,
$preferredUsername: String!, $preferredUsername: String!,
$name: String!, $name: String!,
$summary: String, $summary: String,

19
js/src/graphql/admin.ts Normal file
View File

@ -0,0 +1,19 @@
import gql from 'graphql-tag';
export const DASHBOARD = gql`
query {
dashboard {
lastPublicEventPublished {
title,
picture {
alt
url
},
},
numberOfUsers,
numberOfEvents,
numberOfComments,
numberOfReports
}
}
`;

View File

@ -7,7 +7,8 @@ mutation Login($email: String!, $password: String!) {
refreshToken, refreshToken,
user { user {
id, id,
email email,
role
} }
}, },
} }

View File

@ -70,8 +70,8 @@ export const FETCH_EVENT = gql`
}, },
publishAt, publishAt,
category, category,
online_address, onlineAddress,
phone_address, phoneAddress,
physicalAddress { physicalAddress {
${physicalAddressQuery} ${physicalAddressQuery}
} }
@ -218,8 +218,8 @@ export const CREATE_EVENT = gql`
}, },
publishAt, publishAt,
category, category,
online_address, onlineAddress,
phone_address, phoneAddress,
physicalAddress { physicalAddress {
${physicalAddressQuery} ${physicalAddressQuery}
}, },
@ -240,7 +240,7 @@ export const EDIT_EVENT = gql`
$description: String, $description: String,
$beginsOn: DateTime, $beginsOn: DateTime,
$endsOn: DateTime, $endsOn: DateTime,
$status: Int, $status: EventStatus,
$visibility: EventVisibility $visibility: EventVisibility
$tags: [String], $tags: [String],
$picture: PictureInput, $picture: PictureInput,
@ -280,8 +280,8 @@ export const EDIT_EVENT = gql`
}, },
publishAt, publishAt,
category, category,
online_address, onlineAddress,
phone_address, phoneAddress,
physicalAddress { physicalAddress {
${physicalAddressQuery} ${physicalAddressQuery}
}, },
@ -296,7 +296,7 @@ export const EDIT_EVENT = gql`
`; `;
export const JOIN_EVENT = gql` export const JOIN_EVENT = gql`
mutation JoinEvent($eventId: Int!, $actorId: Int!) { mutation JoinEvent($eventId: ID!, $actorId: ID!) {
joinEvent( joinEvent(
eventId: $eventId, eventId: $eventId,
actorId: $actorId actorId: $actorId
@ -307,7 +307,7 @@ export const JOIN_EVENT = gql`
`; `;
export const LEAVE_EVENT = gql` export const LEAVE_EVENT = gql`
mutation LeaveEvent($eventId: Int!, $actorId: Int!) { mutation LeaveEvent($eventId: ID!, $actorId: ID!) {
leaveEvent( leaveEvent(
eventId: $eventId, eventId: $eventId,
actorId: $actorId actorId: $actorId
@ -320,9 +320,9 @@ export const LEAVE_EVENT = gql`
`; `;
export const DELETE_EVENT = gql` export const DELETE_EVENT = gql`
mutation DeleteEvent($id: Int!, $actorId: Int!) { mutation DeleteEvent($eventId: ID!, $actorId: ID!) {
deleteEvent( deleteEvent(
eventId: $id, eventId: $eventId,
actorId: $actorId actorId: $actorId
) { ) {
id id

View File

@ -12,7 +12,7 @@ query {
}`; }`;
export const CREATE_FEED_TOKEN_ACTOR = gql` export const CREATE_FEED_TOKEN_ACTOR = gql`
mutation createFeedToken($actor_id: Int!) { mutation createFeedToken($actor_id: ID!) {
createFeedToken(actorId: $actor_id) { createFeedToken(actorId: $actor_id) {
token, token,
actor { actor {

161
js/src/graphql/report.ts Normal file
View File

@ -0,0 +1,161 @@
import gql from 'graphql-tag';
export const REPORTS = gql`
query Reports($status: ReportStatus) {
reports(status: $status) {
id,
reported {
id,
preferredUsername,
name,
avatar {
url
}
},
reporter {
id,
preferredUsername,
name,
avatar {
url
}
},
event {
id,
uuid,
title,
picture {
url
}
},
status
}
}
`;
const REPORT_FRAGMENT = gql`
fragment ReportFragment on Report {
id,
reported {
id,
preferredUsername,
name,
avatar {
url
}
},
reporter {
id,
preferredUsername,
name,
avatar {
url
}
},
event {
id,
uuid,
title,
description,
picture {
url
}
},
notes {
id,
content
moderator {
preferredUsername,
name,
avatar {
url
}
},
insertedAt
},
insertedAt,
updatedAt,
status,
content
}
`;
export const REPORT = gql`
query Report($id: ID!) {
report(id: $id) {
...ReportFragment
}
}
${REPORT_FRAGMENT}
`;
export const CREATE_REPORT = gql`
mutation CreateReport(
$eventId: ID!,
$reporterActorId: ID!,
$reportedActorId: ID!,
$content: String
) {
createReport(eventId: $eventId, reporterActorId: $reporterActorId, reportedActorId: $reportedActorId, content: $content) {
id
}
}
`;
export const UPDATE_REPORT = gql`
mutation UpdateReport(
$reportId: ID!,
$moderatorId: ID!,
$status: ReportStatus!
) {
updateReportStatus(reportId: $reportId, moderatorId: $moderatorId, status: $status) {
...ReportFragment
}
}
${REPORT_FRAGMENT}
`;
export const CREATE_REPORT_NOTE = gql`
mutation CreateReportNote(
$reportId: ID!,
$moderatorId: ID!,
$content: String!
) {
createReportNote(reportId: $reportId, moderatorId: $moderatorId, content: $content) {
id,
content,
insertedAt
}
}
`;
export const LOGS = gql`
query {
actionLogs {
id,
action,
actor {
id,
preferredUsername
avatar {
url
}
},
object {
...on Report {
id
},
... on ReportNote {
report {
id,
}
}
... on Event {
id,
title
}
},
insertedAt
}
}
`;

View File

@ -31,12 +31,13 @@ query {
id, id,
email, email,
isLoggedIn, isLoggedIn,
role
} }
} }
`; `;
export const UPDATE_CURRENT_USER_CLIENT = gql` export const UPDATE_CURRENT_USER_CLIENT = gql`
mutation UpdateCurrentUser($id: Int!, $email: String!, $isLoggedIn: Boolean!) { mutation UpdateCurrentUser($id: Int!, $email: String!, $isLoggedIn: Boolean!, $role: UserRole!) {
updateCurrentUser(id: $id, email: $email, isLoggedIn: $isLoggedIn) @client updateCurrentUser(id: $id, email: $email, isLoggedIn: $isLoggedIn, role: $role) @client
} }
`; `;

View File

@ -7,6 +7,7 @@ import App from '@/App.vue';
import router from '@/router'; import router from '@/router';
import { apolloProvider } from './vue-apollo'; import { apolloProvider } from './vue-apollo';
import { NotifierPlugin } from '@/plugins/notifier'; import { NotifierPlugin } from '@/plugins/notifier';
import filters from '@/filters';
const translations = require('@/i18n/translations.json'); const translations = require('@/i18n/translations.json');
@ -14,6 +15,7 @@ Vue.config.productionTip = false;
Vue.use(Buefy); Vue.use(Buefy);
Vue.use(NotifierPlugin); Vue.use(NotifierPlugin);
Vue.use(filters);
const language = (window.navigator as any).userLanguage || window.navigator.language; const language = (window.navigator as any).userLanguage || window.navigator.language;

16
js/src/router/admin.ts Normal file
View File

@ -0,0 +1,16 @@
import { RouteConfig } from 'vue-router';
import Dashboard from '@/views/Admin/Dashboard.vue';
export enum AdminRouteName {
DASHBOARD = 'Dashboard',
}
export const adminRoutes: RouteConfig[] = [
{
path: '/admin',
name: AdminRouteName.DASHBOARD,
component: Dashboard,
props: true,
meta: { requiredAuth: true },
},
];

View File

@ -5,9 +5,11 @@ import Home from '@/views/Home.vue';
import { UserRouteName, userRoutes } from './user'; import { UserRouteName, userRoutes } from './user';
import { EventRouteName, eventRoutes } from '@/router/event'; import { EventRouteName, eventRoutes } from '@/router/event';
import { ActorRouteName, actorRoutes, MyAccountRouteName } from '@/router/actor'; import { ActorRouteName, actorRoutes, MyAccountRouteName } from '@/router/actor';
import { AdminRouteName, adminRoutes } from '@/router/admin';
import { ErrorRouteName, errorRoutes } from '@/router/error'; import { ErrorRouteName, errorRoutes } from '@/router/error';
import { authGuardIfNeeded } from '@/router/guards/auth-guard'; import { authGuardIfNeeded } from '@/router/guards/auth-guard';
import Search from '@/views/Search.vue'; import Search from '@/views/Search.vue';
import { ModerationRouteName, moderationRoutes } from '@/router/moderation';
Vue.use(Router); Vue.use(Router);
@ -35,6 +37,8 @@ export const RouteName = {
...EventRouteName, ...EventRouteName,
...ActorRouteName, ...ActorRouteName,
...MyAccountRouteName, ...MyAccountRouteName,
...AdminRouteName,
...ModerationRouteName,
...ErrorRouteName, ...ErrorRouteName,
}; };
@ -46,6 +50,8 @@ const router = new Router({
...userRoutes, ...userRoutes,
...eventRoutes, ...eventRoutes,
...actorRoutes, ...actorRoutes,
...adminRoutes,
...moderationRoutes,
...errorRoutes, ...errorRoutes,
{ {
path: '/search/:searchTerm/:searchType?', path: '/search/:searchTerm/:searchType?',

View File

@ -0,0 +1,34 @@
import { RouteConfig } from 'vue-router';
import ReportList from '@/views/Moderation/ReportList.vue';
import Report from '@/views/Moderation/Report.vue';
import Logs from '@/views/Moderation/Logs.vue';
export enum ModerationRouteName {
REPORTS = 'Reports',
REPORT = 'Report',
LOGS = 'Logs',
}
export const moderationRoutes: RouteConfig[] = [
{
path: '/moderation/reports/:filter?',
name: ModerationRouteName.REPORTS,
component: ReportList,
props: true,
meta: { requiredAuth: true },
},
{
path: '/moderation/report/:reportId',
name: ModerationRouteName.REPORT,
component: Report,
props: true,
meta: { requiredAuth: true },
},
{
path: '/moderation/logs',
name: ModerationRouteName.LOGS,
component: Logs,
props: true,
meta: { requiredAuth: true },
},
];

View File

@ -0,0 +1,9 @@
import { IEvent } from '@/types/event.model';
export interface IDashboard {
lastPublicEventPublished: IEvent;
numberOfUsers: number;
numberOfEvents: number;
numberOfComments: number;
numberOfReports: number;
}

View File

@ -1,5 +1,12 @@
export enum ICurrentUserRole {
USER = 'USER',
MODERATOR = 'MODERATOR',
ADMINISTRATOR = 'ADMINISTRATOR',
}
export interface ICurrentUser { export interface ICurrentUser {
id: number; id: number;
email: string; email: string;
isLoggedIn: boolean; isLoggedIn: boolean;
role: ICurrentUserRole;
} }

View File

@ -0,0 +1,47 @@
import { IActor, IPerson } from '@/types/actor';
import { IEvent } from '@/types/event.model';
export enum ReportStatusEnum {
OPEN = 'OPEN',
CLOSED = 'CLOSED',
RESOLVED = 'RESOLVED',
}
export interface IReport extends IActionLogObject {
id: string;
reported: IActor;
reporter: IPerson;
event?: IEvent;
content: string;
notes: IReportNote[];
insertedAt: Date;
updatedAt: Date;
status: ReportStatusEnum;
}
export interface IReportNote extends IActionLogObject{
id: string;
content: string;
moderator: IActor;
}
export interface IActionLogObject {
id: string;
}
export enum ActionLogAction {
NOTE_CREATION = 'NOTE_CREATION',
NOTE_DELETION = 'NOTE_DELETION',
REPORT_UPDATE_CLOSED = 'REPORT_UPDATE_CLOSED',
REPORT_UPDATE_OPENED = 'REPORT_UPDATE_OPENED',
REPORT_UPDATE_RESOLVED = 'REPORT_UPDATE_RESOLVED',
EVENT_DELETION = 'EVENT_DELETION',
}
export interface IActionLog {
id: string;
object: IReport|IReportNote|IEvent;
actor: IActor;
action: ActionLogAction;
insertedAt: Date;
}

View File

@ -1,12 +1,14 @@
import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants'; import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID, AUTH_USER_ROLE } from '@/constants';
import { ILogin, IToken } from '@/types/login.model'; import { ILogin, IToken } from '@/types/login.model';
import { UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user'; import { UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user';
import { onLogout } from '@/vue-apollo'; import { onLogout } from '@/vue-apollo';
import ApolloClient from 'apollo-client'; import ApolloClient from 'apollo-client';
import { ICurrentUserRole } from '@/types/current-user.model';
export function saveUserData(obj: ILogin) { export function saveUserData(obj: ILogin) {
localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`); localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`);
localStorage.setItem(AUTH_USER_EMAIL, obj.user.email); localStorage.setItem(AUTH_USER_EMAIL, obj.user.email);
localStorage.setItem(AUTH_USER_ROLE, obj.user.role);
saveTokenData(obj); saveTokenData(obj);
} }
@ -17,7 +19,7 @@ export function saveTokenData(obj: IToken) {
} }
export function deleteUserData() { export function deleteUserData() {
for (const key of [AUTH_USER_ID, AUTH_USER_EMAIL, AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN]) { for (const key of [AUTH_USER_ID, AUTH_USER_EMAIL, AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_ROLE]) {
localStorage.removeItem(key); localStorage.removeItem(key);
} }
} }
@ -29,6 +31,7 @@ export function logout(apollo: ApolloClient<any>) {
id: null, id: null,
email: null, email: null,
isLoggedIn: false, isLoggedIn: false,
role: ICurrentUserRole.USER,
}, },
}); });

View File

@ -0,0 +1,73 @@
<template>
<section class="container">
<h1 class="title">Administration</h1>
<div class="tile is-ancestor" v-if="dashboard">
<div class="tile is-vertical is-4">
<div class="tile">
<div class="tile is-parent is-vertical is-6">
<article class="tile is-child box">
<p class="title">{{ dashboard.numberOfEvents }}</p>
<p class="subtitle">événements publiés</p>
</article>
<article class="tile is-child box">
<p class="title">{{ dashboard.numberOfComments}}</p>
<p class="subtitle">commentaires</p>
</article>
</div>
<div class="tile is-parent is-vertical">
<article class="tile is-child box">
<p class="title">{{ dashboard.numberOfUsers }}</p>
<p class="subtitle">utilisateurices</p>
</article>
<router-link :to="{ name: ModerationRouteName.REPORTS}">
<article class="tile is-child box">
<p class="title">{{ dashboard.numberOfReports }}</p>
<p class="subtitle">signalements ouverts</p>
</article>
</router-link>
</div>
</div>
<div class="tile is-parent" v-if="dashboard.lastPublicEventPublished">
<article class="tile is-child box">
<p class="title">Dernier événement publié</p>
<p class="subtitle">{{ dashboard.lastPublicEventPublished.title }}</p>
<figure class="image is-4by3" v-if="dashboard.lastPublicEventPublished.picture">
<img :src="dashboard.lastPublicEventPublished.picture.url" />
</figure>
</article>
</div>
</div>
<div class="tile is-parent">
<article class="tile is-child box">
<div class="content">
<p class="title">Bienvenue sur votre espace d'administration</p>
<p class="subtitle">With even more content</p>
<div class="content">
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam semper diam at erat pulvinar, at pulvinar felis blandit. Vestibulum volutpat tellus diam, consequat gravida libero rhoncus ut. Morbi maximus, leo sit amet vehicula eleifend, nunc dui porta orci, quis semper odio felis ut quam.</p>
<p>Suspendisse varius ligula in molestie lacinia. Maecenas varius eget ligula a sagittis. Pellentesque interdum, nisl nec interdum maximus, augue diam porttitor lorem, et sollicitudin felis neque sit amet erat. Maecenas imperdiet felis nisi, fringilla luctus felis hendrerit sit amet. Aenean vitae gravida diam, finibus dignissim turpis. Sed eget varius ligula, at volutpat tortor.</p>
<p>Integer sollicitudin, tortor a mattis commodo, velit urna rhoncus erat, vitae congue lectus dolor consequat libero. Donec leo ligula, maximus et pellentesque sed, gravida a metus. Cras ullamcorper a nunc ac porta. Aliquam ut aliquet lacus, quis faucibus libero. Quisque non semper leo.</p>
</div>
</div>
</article>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { DASHBOARD } from '@/graphql/admin';
import { IDashboard } from '@/types/admin.model';
import { ModerationRouteName } from '@/router/moderation';
@Component({
apollo: {
dashboard: {
query: DASHBOARD,
},
},
})
export default class Dashboard extends Vue {
dashboard!: IDashboard;
ModerationRouteName = ModerationRouteName;
}
</script>

View File

@ -53,8 +53,8 @@
</p> </p>
</div> </div>
<div class="column sidebar"> <div class="column sidebar">
<div class="field has-addons" v-if="actorIsOrganizer()"> <div class="field has-addons">
<p class="control"> <p class="control" v-if="actorIsOrganizer()">
<router-link <router-link
class="button" class="button"
:to="{ name: 'EditEvent', params: {eventId: event.uuid}}" :to="{ name: 'EditEvent', params: {eventId: event.uuid}}"
@ -62,11 +62,16 @@
<translate>Edit</translate> <translate>Edit</translate>
</router-link> </router-link>
</p> </p>
<p class="control"> <p class="control" v-if="actorIsOrganizer()">
<a class="button is-danger" @click="openDeleteEventModal()"> <a class="button is-danger" @click="openDeleteEventModal()">
<translate>Delete</translate> <translate>Delete</translate>
</a> </a>
</p> </p>
<p class="control">
<a class="button is-danger" @click="isReportModalActive = true">
<translate>Report</translate>
</a>
</p>
</div> </div>
<div class="address-wrapper"> <div class="address-wrapper">
<b-icon icon="map" /> <b-icon icon="map" />
@ -224,6 +229,9 @@
</div> </div>
</div> </div>
</section> </section>
<b-modal :active.sync="isReportModalActive" has-modal-card>
<report-modal :on-confirm="reportEvent" title="Report this event" :outside-domain="event.organizerActor.domain" />
</b-modal>
</div> </div>
</div> </div>
</template> </template>
@ -241,6 +249,9 @@ import BIcon from 'buefy/src/components/icon/Icon.vue';
import EventCard from '@/components/Event/EventCard.vue'; import EventCard from '@/components/Event/EventCard.vue';
import EventFullDate from '@/components/Event/EventFullDate.vue'; import EventFullDate from '@/components/Event/EventFullDate.vue';
import ActorLink from '@/components/Account/ActorLink.vue'; import ActorLink from '@/components/Account/ActorLink.vue';
import ReportModal from '@/components/Report/ReportModal.vue';
import { IReport } from '@/types/report.model';
import { CREATE_REPORT } from '@/graphql/report';
@Component({ @Component({
components: { components: {
@ -249,6 +260,7 @@ import ActorLink from '@/components/Account/ActorLink.vue';
EventCard, EventCard,
BIcon, BIcon,
DateCalendarIcon, DateCalendarIcon,
ReportModal,
// tslint:disable:space-in-parens // tslint:disable:space-in-parens
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'), 'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
// tslint:enable // tslint:enable
@ -274,6 +286,7 @@ export default class Event extends Vue {
loggedPerson!: IPerson; loggedPerson!: IPerson;
validationSent: boolean = false; validationSent: boolean = false;
showMap: boolean = false; showMap: boolean = false;
isReportModalActive: boolean = false;
EventVisibility = EventVisibility; EventVisibility = EventVisibility;
@ -285,24 +298,47 @@ export default class Event extends Vue {
type: 'is-danger', type: 'is-danger',
title: this.$gettext('Delete event'), title: this.$gettext('Delete event'),
message: this.$gettextInterpolate( message: this.$gettextInterpolate(
`${prefix}` + `${prefix}` +
'Are you sure you want to delete this event? This action cannot be reverted. <br /><br />' + 'Are you sure you want to delete this event? This action cannot be reverted. <br /><br />' +
'To confirm, type your event title "%{eventTitle}"', 'To confirm, type your event title "%{eventTitle}"',
{ participants: this.event.participants.length, eventTitle: this.event.title }, { participants: this.event.participants.length, eventTitle: this.event.title },
), ),
confirmText: this.$gettextInterpolate( confirmText: this.$gettextInterpolate(
'Delete %{eventTitle}', 'Delete %{eventTitle}',
{ eventTitle: this.event.title }, { eventTitle: this.event.title },
), ),
inputAttrs: { inputAttrs: {
placeholder: this.event.title, placeholder: this.event.title,
pattern: this.event.title, pattern: this.event.title,
}, },
onConfirm: () => this.deleteEvent(), onConfirm: () => this.deleteEvent(),
}); });
} }
async reportEvent(content: string, forward: boolean) {
this.isReportModalActive = false;
const eventTitle = this.event.title;
try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
eventId: this.event.id,
reporterActorId: this.loggedPerson.id,
reportedActorId: this.event.organizerActor.id,
content,
},
});
this.$buefy.notification.open({
message: this.$gettextInterpolate('Event %{eventTitle} reported', { eventTitle }),
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
} catch (error) {
console.error(error);
}
}
async joinEvent() { async joinEvent() {
try { try {
await this.$apollo.mutate<{ joinEvent: IParticipant }>({ await this.$apollo.mutate<{ joinEvent: IParticipant }>({
@ -408,7 +444,7 @@ export default class Event extends Vue {
await this.$apollo.mutate<IParticipant>({ await this.$apollo.mutate<IParticipant>({
mutation: DELETE_EVENT, mutation: DELETE_EVENT,
variables: { variables: {
id: this.event.id, eventId: this.event.id,
actorId: this.loggedPerson.id, actorId: this.loggedPerson.id,
}, },
}); });

View File

@ -0,0 +1,80 @@
import {ReportStatusEnum} from "@/types/report.model";
<template>
<section class="container">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: AdminRouteName.DASHBOARD }">Dashboard</router-link></li>
<li class="is-active"><router-link :to="{ name: ModerationRouteName.LOGS }" aria-current="page">Logs</router-link></li>
</ul>
</nav>
<ul v-if="actionLogs.length > 0">
<li v-for="log in actionLogs">
<div class="box">
<img class="image" :src="log.actor.avatar.url" />
<span>@{{ log.actor.preferredUsername }}</span>
<span v-if="log.action === ActionLogAction.REPORT_UPDATE_CLOSED">
closed <router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.id } }">report #{{ log.object.id }}</router-link>
</span>
<span v-else-if="log.action === ActionLogAction.REPORT_UPDATE_OPENED">
reopened <router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.id } }">report #{{ log.object.id }}</router-link>
</span>
<span v-else-if="log.action === ActionLogAction.REPORT_UPDATE_RESOLVED">
marked <router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.id } }">report #{{ log.object.id }}</router-link> as resolved
</span>
<span v-else-if="log.action === ActionLogAction.NOTE_CREATION">
added a note on
<router-link v-if="log.object.report" :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.report.id } }">report #{{ log.object.report.id }}</router-link>
<span v-else>a non-existent report</span>
</span>
<span v-else-if="log.action === ActionLogAction.EVENT_DELETION">
deleted an event named « {{ log.object.title }} »
</span>
<br />
<small>{{ log.insertedAt | formatDateTimeString }}</small>
</div>
<!-- <pre>{{ log }}</pre>-->
</li>
</ul>
<div v-else>
<b-message type="is-info">No moderation logs yet</b-message>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { IActionLog, ActionLogAction } from '@/types/report.model';
import { LOGS } from '@/graphql/report';
import ReportCard from '@/components/Report/ReportCard.vue';
import { AdminRouteName } from '@/router/admin';
import { ModerationRouteName } from '@/router/moderation';
@Component({
components: {
ReportCard,
},
apollo: {
actionLogs: {
query: LOGS,
},
},
})
export default class ReportList extends Vue {
actionLogs?: IActionLog[] = [];
ActionLogAction = ActionLogAction;
AdminRouteName = AdminRouteName;
ModerationRouteName = ModerationRouteName;
}
</script>
<style lang="scss">
.container li {
margin: 10px auto;
}
img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
</style>

View File

@ -0,0 +1,271 @@
<template>
<section class="container">
<b-message title="Error" type="is-danger" v-for="error in errors" :key="error">{{ error }}</b-message>
<div class="container" v-if="report">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: AdminRouteName.DASHBOARD }">Dashboard</router-link></li>
<li><router-link :to="{ name: ModerationRouteName.REPORTS }">Reports</router-link></li>
<li class="is-active"><router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: this.report.id} }" aria-current="page">Report</router-link></li>
</ul>
</nav>
<div class="buttons">
<b-button v-if="report.status !== ReportStatusEnum.RESOLVED" @click="updateReport(ReportStatusEnum.RESOLVED)" type="is-primary">Mark as resolved</b-button>
<b-button v-if="report.status !== ReportStatusEnum.OPEN" @click="updateReport(ReportStatusEnum.OPEN)" type="is-success">Reopen</b-button>
<b-button v-if="report.status !== ReportStatusEnum.CLOSED" @click="updateReport(ReportStatusEnum.CLOSED)" type="is-danger">Close</b-button>
</div>
<div class="columns">
<div class="column">
<div class="table-container">
<table class="box table is-striped">
<tbody>
<tr>
<td>Compte signalé</td>
<td>
<router-link :to="{ name: ActorRouteName.PROFILE, params: { name: report.reported.preferredUsername } }">
<img v-if="report.reported.avatar" class="image" :src="report.reported.avatar.url" /> @{{ report.reported.preferredUsername }}
</router-link>
</td>
</tr>
<tr>
<td>Signalé par</td>
<td>
<router-link :to="{ name: ActorRouteName.PROFILE, params: { name: report.reporter.preferredUsername } }">
<img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}
</router-link>
</td>
</tr>
<tr>
<td>Signalé</td>
<td>{{ report.insertedAt | formatDateTimeString }}</td>
</tr>
<tr v-if="report.updatedAt !== report.insertedAt">
<td>Mis à jour</td>
<td>{{ report.updatedAt | formatDateTimeString }}</td>
</tr>
<tr>
<td>Statut</td>
<td>
<span v-if="report.status === ReportStatusEnum.OPEN">Ouvert</span>
<span v-else-if="report.status === ReportStatusEnum.CLOSED">Fermé</span>
<span v-else-if="report.status === ReportStatusEnum.RESOLVED">Résolu</span>
<span v-else>Inconnu</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="column">
<div class="box">
<p v-if="report.content">{{ report.content }}</p>
<p v-else>Pas de commentaire</p>
</div>
</div>
</div>
<div class="box" v-if="report.event">
<router-link :to="{ name: EventRouteName.EVENT, params: { uuid: report.event.uuid }}">
<h3 class="title">{{ report.event.title }}</h3>
<p v-html="report.event.description"></p>
</router-link>
<b-button
tag="router-link"
type="is-primary"
:to="{ name: EventRouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
icon-left="pencil"
size="is-small">Edit</b-button>
<b-button
type="is-danger"
@click="confirmDelete()"
icon-left="delete"
size="is-small">Delete</b-button>
</div>
<h2 class="title" v-if="report.notes.length > 0">Notes</h2>
<div class="box note" v-for="note in report.notes" :id="`note-${note.id}`">
<p>{{ note.content }}</p>
<router-link :to="{ name: ActorRouteName.PROFILE, params: { name: note.moderator.preferredUsername } }">
<img class="image" :src="note.moderator.avatar.url" /> @{{ note.moderator.preferredUsername }}
</router-link><br />
<small><a :href="`#note-${note.id}`" v-if="note.insertedAt">{{ note.insertedAt | formatDateTimeString }}</a></small>
</div>
<form @submit="addNote()">
<b-field label="Nouvelle note">
<b-input type="textarea" v-model="noteContent"></b-input>
</b-field>
<b-button type="submit" @click="addNote">Ajouter une note</b-button>
</form>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { CREATE_REPORT_NOTE, REPORT, REPORTS, UPDATE_REPORT } from '@/graphql/report';
import { IReport, IReportNote, ReportStatusEnum } from '@/types/report.model';
import { EventRouteName } from '@/router/event';
import { ActorRouteName } from '@/router/actor';
import { AdminRouteName } from '@/router/admin';
import { ModerationRouteName } from '@/router/moderation';
import { LOGGED_PERSON } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import { DELETE_EVENT } from '@/graphql/event';
import { uniq } from 'lodash';
@Component({
apollo: {
report: {
query: REPORT,
variables() {
return {
id: this.reportId,
};
},
error({ graphQLErrors }) {
this.errors = uniq(graphQLErrors.map(({ message }) => message));
},
},
loggedPerson: {
query: LOGGED_PERSON,
},
},
})
export default class Report extends Vue {
@Prop({ required: true }) reportId!: number;
report!: IReport;
loggedPerson!: IPerson;
errors: string[] = [];
ReportStatusEnum = ReportStatusEnum;
EventRouteName = EventRouteName;
ActorRouteName = ActorRouteName;
AdminRouteName = AdminRouteName;
ModerationRouteName = ModerationRouteName;
noteContent: string = '';
addNote() {
try {
this.$apollo.mutate<{ createReportNote: IReportNote }>({
mutation: CREATE_REPORT_NOTE,
variables: {
reportId: this.report.id,
moderatorId: this.loggedPerson.id,
content: this.noteContent,
},
update: (store, { data }) => {
if (data == null) return;
const cachedData = store.readQuery<{ report: IReport }>({ query: REPORT, variables: { id: this.report.id } });
if (cachedData == null) return;
const { report } = cachedData;
if (report === null) {
console.error('Cannot update event notes cache, because of null value.');
return;
}
const note = data.createReportNote;
note.moderator = this.loggedPerson;
report.notes = report.notes.concat([note]);
store.writeQuery({ query: REPORT, data: { report } });
},
});
this.noteContent = '';
} catch (error) {
console.error(error);
}
}
confirmDelete() {
this.$buefy.dialog.confirm({
title: 'Deleting event',
message: 'Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.',
confirmText: 'Delete Event',
type: 'is-danger',
hasIcon: true,
onConfirm: () => this.deleteEvent(),
});
}
async deleteEvent() {
if (!this.report.event || !this.report.event.id) return;
const eventTitle = this.report.event.title;
try {
await this.$apollo.mutate({
mutation: DELETE_EVENT,
variables: {
eventId: this.report.event.id.toString(),
actorId: this.loggedPerson.id,
},
});
this.$buefy.notification.open({
message: this.$gettextInterpolate('Event %{eventTitle} deleted', { eventTitle }),
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
} catch (error) {
console.error(error);
}
}
async updateReport(status: ReportStatusEnum) {
try {
await this.$apollo.mutate({
mutation: UPDATE_REPORT,
variables: {
reportId: this.report.id,
moderatorId: this.loggedPerson.id,
status,
},
update: (store, { data }) => {
if (data == null) return;
const reportCachedData = store.readQuery<{ report: IReport }>({ query: REPORT, variables: { id: this.report.id } });
if (reportCachedData == null) return;
const { report } = reportCachedData;
if (report === null) {
console.error('Cannot update event notes cache, because of null value.');
return;
}
const updatedReport = data.updateReportStatus;
report.status = updatedReport.status;
store.writeQuery({ query: REPORT, data: { report } });
},
});
} catch (error) {
console.error(error);
}
}
// TODO make me a global function
formatDate(value) {
return value ? new Date(value).toLocaleString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) : null;
}
formatTime(value) {
return value ? new Date(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' }) : null;
}
}
</script>
<style lang="scss">
.container li {
margin: 10px auto;
}
tbody td img.image, .note img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
.dialog .modal-card-foot {
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,90 @@
import {ReportStatusEnum} from "@/types/report.model";
<template>
<section class="container">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: AdminRouteName.DASHBOARD }">Dashboard</router-link></li>
<li class="is-active"><router-link :to="{ name: ModerationRouteName.REPORTS }" aria-current="page">Reports</router-link></li>
</ul>
</nav>
<b-field>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.OPEN">
Ouvert
</b-radio-button>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.RESOLVED">
Résolus
</b-radio-button>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.CLOSED">
Fermés
</b-radio-button>
</b-field>
<ul v-if="reports.length > 0">
<li v-for="report in reports">
<router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: report.id } }">
<report-card :report="report" />
</router-link>
</li>
</ul>
<div v-else>
<b-message v-if="filterReports === ReportStatusEnum.OPEN" type="is-info">No open reports yet</b-message>
<b-message v-if="filterReports === ReportStatusEnum.RESOLVED" type="is-info">No resolved reports yet</b-message>
<b-message v-if="filterReports === ReportStatusEnum.CLOSED" type="is-info">No closed reports yet</b-message>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { IReport, ReportStatusEnum } from '@/types/report.model';
import { REPORTS } from '@/graphql/report';
import ReportCard from '@/components/Report/ReportCard.vue';
import { AdminRouteName } from '@/router/admin';
import { ModerationRouteName } from '@/router/moderation';
@Component({
components: {
ReportCard,
},
apollo: {
reports: {
query: REPORTS,
fetchPolicy: 'no-cache',
variables() {
return {
status: this.filterReports,
};
},
pollInterval: 120000, // 2 minutes
},
},
})
export default class ReportList extends Vue {
reports?: IReport[] = [];
AdminRouteName = AdminRouteName;
ModerationRouteName = ModerationRouteName;
ReportStatusEnum = ReportStatusEnum;
filterReports: ReportStatusEnum = ReportStatusEnum.OPEN;
@Watch('$route.params.filter', { immediate: true })
onRouteFilterChanged (val: string) {
if (!val) return;
const filter = val.toUpperCase();
if (filter in ReportStatusEnum) {
this.filterReports = filter as ReportStatusEnum;
}
}
@Watch('filterReports', { immediate: true })
async onFilterChanged (val: string) {
await this.$router.push({ name: ModerationRouteName.REPORTS, params: { filter: val.toLowerCase() } });
}
}
</script>
<style lang="scss">
.container li {
margin: 10px auto;
}
</style>

View File

@ -143,6 +143,7 @@ export default class Login extends Vue {
id: data.login.user.id, id: data.login.user.id,
email: this.credentials.email, email: this.credentials.email,
isLoggedIn: true, isLoggedIn: true,
role: data.login.user.role,
}, },
}); });

View File

@ -22,7 +22,8 @@ defmodule Mobilizon.Admin do
def list_action_logs(page \\ nil, limit \\ nil) do def list_action_logs(page \\ nil, limit \\ nil) do
from( from(
r in ActionLog, r in ActionLog,
preload: [:actor] preload: [:actor],
order_by: [desc: :id]
) )
|> paginate(page, limit) |> paginate(page, limit)
|> Repo.all() |> Repo.all()

View File

@ -1,3 +1,11 @@
import EctoEnum
defenum(Mobilizon.Admin.ActionLogAction, [
"update",
"create",
"delete"
])
defmodule Mobilizon.Admin.ActionLog do defmodule Mobilizon.Admin.ActionLog do
@moduledoc """ @moduledoc """
ActionLog entity schema ActionLog entity schema
@ -5,11 +13,13 @@ defmodule Mobilizon.Admin.ActionLog do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Admin.ActionLogAction
@timestamps_opts [type: :utc_datetime]
@required_attrs [:action, :target_type, :target_id, :changes, :actor_id] @required_attrs [:action, :target_type, :target_id, :changes, :actor_id]
schema "admin_action_logs" do schema "admin_action_logs" do
field(:action, :string) field(:action, ActionLogAction)
field(:target_type, :string) field(:target_type, :string)
field(:target_id, :integer) field(:target_id, :integer)
field(:changes, :map) field(:changes, :map)

View File

@ -54,6 +54,21 @@ defmodule Mobilizon.Application do
], ],
id: :cache_ics id: :cache_ics
), ),
worker(
Cachex,
[
:statistics,
[
limit: 10,
expiration:
expiration(
default: :timer.minutes(60),
interval: :timer.seconds(60)
)
]
],
id: :cache_statistics
),
worker( worker(
Cachex, Cachex,
[ [

View File

@ -42,6 +42,7 @@ defmodule Mobilizon.Events.EventOptions do
} }
@primary_key false @primary_key false
@derive Jason.Encoder
embedded_schema do embedded_schema do
field(:maximum_attendee_capacity, :integer) field(:maximum_attendee_capacity, :integer)
field(:remaining_attendee_capacity, :integer) field(:remaining_attendee_capacity, :integer)

View File

@ -30,16 +30,28 @@ defmodule Mobilizon.Reports do
""" """
@spec list_reports(integer(), integer(), atom(), atom()) :: list(Report.t()) @spec list_reports(integer(), integer(), atom(), atom()) :: list(Report.t())
def list_reports(page \\ nil, limit \\ nil, sort \\ :updated_at, direction \\ :asc) do def list_reports(
page \\ nil,
limit \\ nil,
sort \\ :updated_at,
direction \\ :desc,
status \\ :open
) do
from( from(
r in Report, r in Report,
preload: [:reported, :reporter, :manager, :event, :comments, :notes] preload: [:reported, :reporter, :manager, :event, :comments, :notes],
where: r.status == ^status
) )
|> paginate(page, limit) |> paginate(page, limit)
|> sort(sort, direction) |> sort(sort, direction)
|> Repo.all() |> Repo.all()
end end
def count_opened_reports() do
query = from(r in Report, where: r.status == ^:open)
Repo.aggregate(query, :count, :id)
end
@doc """ @doc """
Gets a single report. Gets a single report.

View File

@ -7,6 +7,7 @@ defmodule Mobilizon.Reports.Note do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Reports.Report alias Mobilizon.Reports.Report
@timestamps_opts [type: :utc_datetime]
@attrs [:content, :moderator_id, :report_id] @attrs [:content, :moderator_id, :report_id]
@derive {Jason.Encoder, only: [:content]} @derive {Jason.Encoder, only: [:content]}

View File

@ -17,6 +17,8 @@ defmodule Mobilizon.Reports.Report do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Reports.Note alias Mobilizon.Reports.Note
@timestamps_opts [type: :utc_datetime]
@derive {Jason.Encoder, only: [:status, :uri]} @derive {Jason.Encoder, only: [:status, :uri]}
schema "reports" do schema "reports" do
field(:content, :string) field(:content, :string)
@ -48,7 +50,7 @@ defmodule Mobilizon.Reports.Report do
def changeset(report, attrs) do def changeset(report, attrs) do
report report
|> cast(attrs, [:content, :status, :uri, :reported_id, :reporter_id, :manager_id, :event_id]) |> cast(attrs, [:content, :status, :uri, :reported_id, :reporter_id, :manager_id, :event_id])
|> validate_required([:content, :uri, :reported_id, :reporter_id]) |> validate_required([:uri, :reported_id, :reporter_id])
end end
def creation_changeset(report, attrs) do def creation_changeset(report, attrs) do

View File

@ -135,4 +135,13 @@ defmodule MobilizonWeb.API.Events do
} }
end end
end end
@doc """
Trigger the deletion of an event
If the event is deleted by
"""
def delete_event(%Event{} = event, federate \\ true) do
ActivityPub.delete(event, federate)
end
end end

View File

@ -21,21 +21,17 @@ defmodule MobilizonWeb.API.Reports do
def report( def report(
%{ %{
reporter_actor_id: reporter_actor_id, reporter_actor_id: reporter_actor_id,
reported_actor_id: reported_actor_id, reported_actor_id: reported_actor_id
event_id: event_id,
comments_ids: comments_ids,
report_content: report_content
} = args } = args
) do ) do
with {:reporter, %Actor{url: reporter_url} = _reporter_actor} <- with {:reporter, %Actor{url: reporter_url} = _reporter_actor} <-
{:reporter, Actors.get_actor!(reporter_actor_id)}, {:reporter, Actors.get_actor!(reporter_actor_id)},
{:reported, %Actor{url: reported_actor_url} = reported_actor} <- {:reported, %Actor{url: reported_actor_url} = reported_actor} <-
{:reported, Actors.get_actor!(reported_actor_id)}, {:reported, Actors.get_actor!(reported_actor_id)},
{:ok, content} <- make_report_content_html(report_content), {:ok, content} <- args |> Map.get(:content, nil) |> make_report_content_text(),
{:ok, event} <- {:ok, event} <- args |> Map.get(:event_id, nil) |> get_event(),
if(event_id, do: Events.get_event(event_id), else: {:ok, nil}),
{:get_report_comments, comments_urls} <- {:get_report_comments, comments_urls} <-
get_report_comments(reported_actor, comments_ids), get_report_comments(reported_actor, Map.get(args, :comments_ids, [])),
{:make_activity, {:ok, %Activity{} = activity, %Report{} = report}} <- {:make_activity, {:ok, %Activity{} = activity, %Report{} = report}} <-
{:make_activity, {:make_activity,
ActivityPub.flag(%{ ActivityPub.flag(%{
@ -49,6 +45,7 @@ defmodule MobilizonWeb.API.Reports do
})} do })} do
{:ok, activity, report} {:ok, activity, report}
else else
{:make_activity, err} -> {:error, err}
{:error, err} -> {:error, err} {:error, err} -> {:error, err}
{:actor_id, %{}} -> {:error, "Valid `actor_id` required"} {:actor_id, %{}} -> {:error, "Valid `actor_id` required"}
{:reporter, nil} -> {:error, "Reporter Actor not found"} {:reporter, nil} -> {:error, "Reporter Actor not found"}
@ -56,6 +53,9 @@ defmodule MobilizonWeb.API.Reports do
end end
end end
defp get_event(nil), do: {:ok, nil}
defp get_event(event_id), do: Events.get_event(event_id)
@doc """ @doc """
Update the state of a report Update the state of a report
""" """

View File

@ -122,9 +122,9 @@ defmodule MobilizonWeb.API.Utils do
# |> Formatter.html_escape("text/html") # |> Formatter.html_escape("text/html")
# end # end
def make_report_content_html(nil), do: {:ok, {nil, [], []}} def make_report_content_text(nil), do: {:ok, nil}
def make_report_content_html(comment) do def make_report_content_text(comment) do
max_size = Mobilizon.CommonConfig.get([:instance, :max_report_comment_size], 1000) max_size = Mobilizon.CommonConfig.get([:instance, :max_report_comment_size], 1000)
if String.length(comment) <= max_size do if String.length(comment) <= max_size do

View File

@ -6,8 +6,8 @@
defmodule MobilizonWeb.NodeInfoController do defmodule MobilizonWeb.NodeInfoController do
use MobilizonWeb, :controller use MobilizonWeb, :controller
alias Mobilizon.{Events, Users}
alias Mobilizon.CommonConfig alias Mobilizon.CommonConfig
alias Mobilizon.Service.Statistics
@instance Application.get_env(:mobilizon, :instance) @instance Application.get_env(:mobilizon, :instance)
@node_info_supported_versions ["2.0", "2.1"] @node_info_supported_versions ["2.0", "2.1"]
@ -34,7 +34,7 @@ defmodule MobilizonWeb.NodeInfoController do
response = %{ response = %{
version: version, version: version,
software: %{ software: %{
name: "mobilizon", name: "Mobilizon",
version: Keyword.get(@instance, :version) version: Keyword.get(@instance, :version)
}, },
protocols: ["activitypub"], protocols: ["activitypub"],
@ -45,10 +45,10 @@ defmodule MobilizonWeb.NodeInfoController do
openRegistrations: CommonConfig.registrations_open?(), openRegistrations: CommonConfig.registrations_open?(),
usage: %{ usage: %{
users: %{ users: %{
total: Users.count_users() total: Statistics.get_cached_value(:local_users)
}, },
localPosts: Events.count_local_events(), localPosts: Statistics.get_cached_value(:local_events),
localComments: Events.count_local_comments() localComments: Statistics.get_cached_value(:local_comments)
}, },
metadata: %{ metadata: %{
nodeName: CommonConfig.instance_name(), nodeName: CommonConfig.instance_name(),

View File

@ -2,10 +2,13 @@ defmodule MobilizonWeb.Resolvers.Admin do
@moduledoc """ @moduledoc """
Handles the report-related GraphQL calls Handles the report-related GraphQL calls
""" """
alias Mobilizon.Events
alias Mobilizon.Users.User alias Mobilizon.Users.User
import Mobilizon.Users.Guards import Mobilizon.Users.Guards
alias Mobilizon.Admin.ActionLog alias Mobilizon.Admin.ActionLog
alias Mobilizon.Reports.{Report, Note} alias Mobilizon.Reports.{Report, Note}
alias Mobilizon.Events.Event
alias Mobilizon.Service.Statistics
def list_action_logs(_parent, %{page: page, limit: limit}, %{ def list_action_logs(_parent, %{page: page, limit: limit}, %{
context: %{current_user: %User{role: role}} context: %{current_user: %User{role: role}}
@ -17,14 +20,19 @@ defmodule MobilizonWeb.Resolvers.Admin do
target_type: target_type, target_type: target_type,
action: action, action: action,
actor: actor, actor: actor,
id: id id: id,
inserted_at: inserted_at
} = action_log -> } = action_log ->
transform_action_log(target_type, action, action_log) with data when is_map(data) <-
|> Map.merge(%{ transform_action_log(String.to_existing_atom(target_type), action, action_log) do
actor: actor, Map.merge(data, %{
id: id actor: actor,
}) id: id,
inserted_at: inserted_at
})
end
end) end)
|> Enum.filter(& &1)
{:ok, action_logs} {:ok, action_logs}
end end
@ -35,38 +43,87 @@ defmodule MobilizonWeb.Resolvers.Admin do
end end
defp transform_action_log( defp transform_action_log(
"Elixir.Mobilizon.Reports.Report", Report,
"update", :update,
%ActionLog{} = action_log %ActionLog{} = action_log
) do ) do
with %Report{status: status} = report <- Mobilizon.Reports.get_report(action_log.target_id) do with %Report{} = report <- Mobilizon.Reports.get_report(action_log.target_id) do
action =
case action_log do
%ActionLog{changes: %{"status" => "closed"}} -> :report_update_closed
%ActionLog{changes: %{"status" => "open"}} -> :report_update_opened
%ActionLog{changes: %{"status" => "resolved"}} -> :report_update_resolved
end
%{ %{
action: "report_update_" <> to_string(status), action: action,
object: report object: report
} }
end end
end end
defp transform_action_log("Elixir.Mobilizon.Reports.Note", "create", %ActionLog{ defp transform_action_log(Note, :create, %ActionLog{
changes: changes changes: changes
}) do }) do
%{ %{
action: "note_creation", action: :note_creation,
object: convert_changes_to_struct(Note, changes) object: convert_changes_to_struct(Note, changes)
} }
end end
defp transform_action_log("Elixir.Mobilizon.Reports.Note", "delete", %ActionLog{ defp transform_action_log(Note, :delete, %ActionLog{
changes: changes changes: changes
}) do }) do
%{ %{
action: "note_deletion", action: :note_deletion,
object: convert_changes_to_struct(Note, changes) object: convert_changes_to_struct(Note, changes)
} }
end end
defp transform_action_log(Event, :delete, %ActionLog{
changes: changes
}) do
%{
action: :event_deletion,
object: convert_changes_to_struct(Event, changes)
}
end
# Changes are stored as %{"key" => "value"} so we need to convert them back as struct # Changes are stored as %{"key" => "value"} so we need to convert them back as struct
defp convert_changes_to_struct(struct, %{"report_id" => _report_id} = changes) do
with data <- for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val}),
data <- Map.put(data, :report, Mobilizon.Reports.get_report(data.report_id)) do
struct(struct, data)
end
end
defp convert_changes_to_struct(struct, changes) do defp convert_changes_to_struct(struct, changes) do
struct(struct, for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val})) with data <- for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val}) do
struct(struct, data)
end
end
def get_dashboard(_parent, _args, %{
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
last_public_event_published =
case Events.list_events(1, 1, :inserted_at, :desc) do
[event | _] -> event
_ -> nil
end
{:ok,
%{
number_of_users: Statistics.get_cached_value(:local_users),
number_of_events: Statistics.get_cached_value(:local_events),
number_of_comments: Statistics.get_cached_value(:local_comments),
number_of_reports: Mobilizon.Reports.count_opened_reports(),
last_public_event_published: last_public_event_published
}}
end
def get_dashboard(_parent, _args, _resolution) do
{:error, "You need to be logged-in and an administrator to access dashboard statistics"}
end end
end end

View File

@ -9,7 +9,10 @@ defmodule MobilizonWeb.Resolvers.Event do
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Media.Picture alias Mobilizon.Media.Picture
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias MobilizonWeb.Resolvers.Person alias MobilizonWeb.Resolvers.Person
import Mobilizon.Service.Admin.ActionLogService
# We limit the max number of events that can be retrieved # We limit the max number of events that can be retrieved
@event_max_limit 100 @event_max_limit 100
@ -328,28 +331,43 @@ defmodule MobilizonWeb.Resolvers.Event do
%{event_id: event_id, actor_id: actor_id}, %{event_id: event_id, actor_id: actor_id},
%{ %{
context: %{ context: %{
current_user: user current_user: %User{role: role} = user
} }
} }
) do ) do
with {:ok, %Event{} = event} <- Mobilizon.Events.get_event(event_id), with {:ok, %Event{local: is_local} = event} <- Mobilizon.Events.get_event_full(event_id),
{:is_owned, true, _} <- User.owns_actor(user, actor_id), {actor_id, ""} <- Integer.parse(actor_id),
{:event_can_be_managed, true} <- Event.can_event_be_managed_by(event, actor_id), {:is_owned, true, _} <- User.owns_actor(user, actor_id) do
event <- Mobilizon.Events.delete_event!(event) do cond do
{:ok, %{id: event.id}} Event.can_event_be_managed_by(event, actor_id) == {:event_can_be_managed, true} ->
do_delete_event(event)
role in [:moderator, :administrator] ->
with {:ok, res} <- do_delete_event(event, !is_local),
%Actor{} = actor <- Actors.get_actor(actor_id) do
log_action(actor, "delete", event)
{:ok, res}
end
true ->
{:error, "You cannot delete this event"}
end
else else
{:error, :event_not_found} -> {:error, :event_not_found} ->
{:error, "Event not found"} {:error, "Event not found"}
{:is_owned, false} -> {:is_owned, false} ->
{:error, "Actor id is not owned by authenticated user"} {:error, "Actor id is not owned by authenticated user"}
{:event_can_be_managed, false} ->
{:error, "You cannot delete this event"}
end end
end end
def delete_event(_parent, _args, _resolution) do def delete_event(_parent, _args, _resolution) do
{:error, "You need to be logged-in to delete an event"} {:error, "You need to be logged-in to delete an event"}
end end
defp do_delete_event(event, federate \\ true) when is_boolean(federate) do
with {:ok, _activity, event} <- MobilizonWeb.API.Events.delete_event(event) do
{:ok, %{id: event.id}}
end
end
end end

View File

@ -89,7 +89,9 @@ defmodule MobilizonWeb.Resolvers.Group do
} }
} }
) do ) do
with {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id), with {actor_id, ""} <- Integer.parse(actor_id),
{group_id, ""} <- Integer.parse(group_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
{:is_owned, true, _} <- User.owns_actor(user, actor_id), {:is_owned, true, _} <- User.owns_actor(user, actor_id),
{:ok, %Member{} = member} <- Member.get_member(actor_id, group.id), {:ok, %Member{} = member} <- Member.get_member(actor_id, group.id),
{:is_admin, true} <- Member.is_administrator(member), {:is_admin, true} <- Member.is_administrator(member),
@ -126,7 +128,9 @@ defmodule MobilizonWeb.Resolvers.Group do
} }
} }
) do ) do
with {:is_owned, true, actor} <- User.owns_actor(user, actor_id), with {actor_id, ""} <- Integer.parse(actor_id),
{group_id, ""} <- Integer.parse(group_id),
{:is_owned, true, actor} <- User.owns_actor(user, actor_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id), {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
{:error, :member_not_found} <- Member.get_member(actor.id, group.id), {:error, :member_not_found} <- Member.get_member(actor.id, group.id),
{:is_able_to_join, true} <- {:is_able_to_join, Member.can_be_joined(group)}, {:is_able_to_join, true} <- {:is_able_to_join, Member.can_be_joined(group)},
@ -180,7 +184,9 @@ defmodule MobilizonWeb.Resolvers.Group do
} }
} }
) do ) do
with {:is_owned, true, actor} <- User.owns_actor(user, actor_id), with {actor_id, ""} <- Integer.parse(actor_id),
{group_id, ""} <- Integer.parse(group_id),
{:is_owned, true, actor} <- User.owns_actor(user, actor_id),
{:ok, %Member{} = member} <- Member.get_member(actor.id, group_id), {:ok, %Member{} = member} <- Member.get_member(actor.id, group_id),
{:only_administrator, false} <- {:only_administrator, false} <-
{:only_administrator, check_that_member_is_not_last_administrator(group_id, actor_id)}, {:only_administrator, check_that_member_is_not_last_administrator(group_id, actor_id)},

View File

@ -10,11 +10,11 @@ defmodule MobilizonWeb.Resolvers.Report do
alias MobilizonWeb.API.Reports, as: ReportsAPI alias MobilizonWeb.API.Reports, as: ReportsAPI
import Mobilizon.Users.Guards import Mobilizon.Users.Guards
def list_reports(_parent, %{page: page, limit: limit}, %{ def list_reports(_parent, %{page: page, limit: limit, status: status}, %{
context: %{current_user: %User{role: role}} context: %{current_user: %User{role: role}}
}) })
when is_moderator(role) do when is_moderator(role) do
{:ok, Mobilizon.Reports.list_reports(page, limit)} {:ok, Mobilizon.Reports.list_reports(page, limit, :updated_at, :desc, status)}
end end
def list_reports(_parent, _args, _resolution) do def list_reports(_parent, _args, _resolution) do
@ -25,7 +25,13 @@ defmodule MobilizonWeb.Resolvers.Report do
context: %{current_user: %User{role: role}} context: %{current_user: %User{role: role}}
}) })
when is_moderator(role) do when is_moderator(role) do
{:ok, Mobilizon.Reports.get_report(id)} case Mobilizon.Reports.get_report(id) do
%Report{} = report ->
{:ok, report}
nil ->
{:error, "Report not found"}
end
end end
def get_report(_parent, _args, _resolution) do def get_report(_parent, _args, _resolution) do

View File

@ -78,6 +78,9 @@ defmodule MobilizonWeb.Router do
get("/events/create", PageController, :index) get("/events/create", PageController, :index)
get("/events/list", PageController, :index) get("/events/list", PageController, :index)
get("/events/:uuid/edit", PageController, :index) get("/events/:uuid/edit", PageController, :index)
# This is a hack to ease link generation into emails
get("/moderation/reports/:id", PageController, :index, as: "moderation_report")
end end
scope "/", MobilizonWeb do scope "/", MobilizonWeb do

View File

@ -4,7 +4,7 @@ defmodule MobilizonWeb.Schema do
""" """
use Absinthe.Schema use Absinthe.Schema
alias Mobilizon.{Actors, Events, Users, Addresses, Media} alias Mobilizon.{Actors, Events, Users, Addresses, Media, Reports}
alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.{Event, Comment, Participant} alias Mobilizon.Events.{Event, Comment, Participant}
@ -26,7 +26,7 @@ defmodule MobilizonWeb.Schema do
@desc "A struct containing the id of the deleted object" @desc "A struct containing the id of the deleted object"
object :deleted_object do object :deleted_object do
field(:id, :integer) field(:id, :id)
end end
@desc "A JWT and the associated user ID" @desc "A JWT and the associated user ID"
@ -44,7 +44,7 @@ defmodule MobilizonWeb.Schema do
Represents a notification for an user Represents a notification for an user
""" """
object :notification do object :notification do
field(:id, :integer, description: "The notification ID") field(:id, :id, description: "The notification ID")
field(:user, :user, description: "The user to transmit the notification to") field(:user, :user, description: "The user to transmit the notification to")
field(:actor, :actor, description: "The notification target profile") field(:actor, :actor, description: "The notification target profile")
@ -94,6 +94,7 @@ defmodule MobilizonWeb.Schema do
|> Dataloader.add_source(Events, Events.data()) |> Dataloader.add_source(Events, Events.data())
|> Dataloader.add_source(Addresses, Addresses.data()) |> Dataloader.add_source(Addresses, Addresses.data())
|> Dataloader.add_source(Media, Media.data()) |> Dataloader.add_source(Media, Media.data())
|> Dataloader.add_source(Reports, Reports.data())
Map.put(ctx, :loader, loader) Map.put(ctx, :loader, loader)
end end

View File

@ -13,7 +13,7 @@ defmodule MobilizonWeb.Schema.ActorInterface do
@desc "An ActivityPub actor" @desc "An ActivityPub actor"
interface :actor do interface :actor do
field(:id, :integer, description: "Internal ID for this actor") field(:id, :id, description: "Internal ID for this actor")
field(:url, :string, description: "The ActivityPub actor's URL") field(:url, :string, description: "The ActivityPub actor's URL")
field(:type, :actor_type, description: "The type of Actor (Person, Group,…)") field(:type, :actor_type, description: "The type of Actor (Person, Group,…)")
field(:name, :string, description: "The actor's displayed name") field(:name, :string, description: "The actor's displayed name")

View File

@ -14,7 +14,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
object :group do object :group do
interfaces([:actor]) interfaces([:actor])
field(:id, :integer, description: "Internal ID for this group") field(:id, :id, description: "Internal ID for this group")
field(:url, :string, description: "The ActivityPub actor's URL") field(:url, :string, description: "The ActivityPub actor's URL")
field(:type, :actor_type, description: "The type of Actor (Person, Group,…)") field(:type, :actor_type, description: "The type of Actor (Person, Group,…)")
field(:name, :string, description: "The actor's displayed name") field(:name, :string, description: "The actor's displayed name")
@ -96,9 +96,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
field :create_group, :group do field :create_group, :group do
arg(:preferred_username, non_null(:string), description: "The name for the group") arg(:preferred_username, non_null(:string), description: "The name for the group")
arg(:creator_actor_id, non_null(:integer), arg(:creator_actor_id, non_null(:id), description: "The identity that creates the group")
description: "The identity that creates the group"
)
arg(:name, :string, description: "The displayed name for the group") arg(:name, :string, description: "The displayed name for the group")
arg(:summary, :string, description: "The summary for the group", default_value: "") arg(:summary, :string, description: "The summary for the group", default_value: "")
@ -118,8 +116,8 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
@desc "Delete a group" @desc "Delete a group"
field :delete_group, :deleted_object do field :delete_group, :deleted_object do
arg(:group_id, non_null(:integer)) arg(:group_id, non_null(:id))
arg(:actor_id, non_null(:integer)) arg(:actor_id, non_null(:id))
resolve(&Group.delete_group/3) resolve(&Group.delete_group/3)
end end

View File

@ -24,16 +24,16 @@ defmodule MobilizonWeb.Schema.Actors.MemberType do
object :member_mutations do object :member_mutations do
@desc "Join a group" @desc "Join a group"
field :join_group, :member do field :join_group, :member do
arg(:group_id, non_null(:integer)) arg(:group_id, non_null(:id))
arg(:actor_id, non_null(:integer)) arg(:actor_id, non_null(:id))
resolve(&Resolvers.Group.join_group/3) resolve(&Resolvers.Group.join_group/3)
end end
@desc "Leave an event" @desc "Leave an event"
field :leave_group, :deleted_member do field :leave_group, :deleted_member do
arg(:group_id, non_null(:integer)) arg(:group_id, non_null(:id))
arg(:actor_id, non_null(:integer)) arg(:actor_id, non_null(:id))
resolve(&Resolvers.Group.leave_group/3) resolve(&Resolvers.Group.leave_group/3)
end end

View File

@ -15,7 +15,7 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
""" """
object :person do object :person do
interfaces([:actor]) interfaces([:actor])
field(:id, :integer, description: "Internal ID for this person") field(:id, :id, description: "Internal ID for this person")
field(:user, :user, description: "The user this actor is associated to") field(:user, :user, description: "The user this actor is associated to")
field(:member_of, list_of(:member), description: "The list of groups this person is member of") field(:member_of, list_of(:member), description: "The list of groups this person is member of")

View File

@ -15,7 +15,7 @@ defmodule MobilizonWeb.Schema.AddressType do
field(:country, :string) field(:country, :string)
field(:description, :string) field(:description, :string)
field(:url, :string) field(:url, :string)
field(:id, :integer) field(:id, :id)
field(:origin_id, :string) field(:origin_id, :string)
end end
@ -40,7 +40,7 @@ defmodule MobilizonWeb.Schema.AddressType do
field(:country, :string) field(:country, :string)
field(:description, :string) field(:description, :string)
field(:url, :string) field(:url, :string)
field(:id, :integer) field(:id, :id)
field(:origin_id, :string) field(:origin_id, :string)
end end

View File

@ -5,13 +5,25 @@ defmodule MobilizonWeb.Schema.AdminType do
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
alias MobilizonWeb.Resolvers.Admin alias MobilizonWeb.Resolvers.Admin
alias Mobilizon.Reports.{Report, Note} alias Mobilizon.Reports.{Report, Note}
alias Mobilizon.Events.Event
@desc "An action log" @desc "An action log"
object :action_log do object :action_log do
field(:id, :id, description: "Internal ID for this comment") field(:id, :id, description: "Internal ID for this comment")
field(:actor, :actor, description: "The actor that acted") field(:actor, :actor, description: "The actor that acted")
field(:object, :action_log_object, description: "The object that was acted upon") field(:object, :action_log_object, description: "The object that was acted upon")
field(:action, :string, description: "The action that was done") field(:action, :action_log_action, description: "The action that was done")
field(:inserted_at, :datetime, description: "The time when the action was performed")
end
enum :action_log_action do
value(:report_update_closed)
value(:report_update_opened)
value(:report_update_resolved)
value(:note_creation)
value(:note_deletion)
value(:event_deletion)
value(:event_update)
end end
@desc "The objects that can be in an action log" @desc "The objects that can be in an action log"
@ -25,11 +37,22 @@ defmodule MobilizonWeb.Schema.AdminType do
%Note{}, _ -> %Note{}, _ ->
:report_note :report_note
%Event{}, _ ->
:event
_, _ -> _, _ ->
nil nil
end) end)
end end
object :dashboard do
field(:last_public_event_published, :event, description: "Last public event publish")
field(:number_of_users, :integer, description: "The number of local users")
field(:number_of_events, :integer, description: "The number of local events")
field(:number_of_comments, :integer, description: "The number of local comments")
field(:number_of_reports, :integer, description: "The number of current opened reports")
end
object :admin_queries do object :admin_queries do
@desc "Get the list of action logs" @desc "Get the list of action logs"
field :action_logs, type: list_of(:action_log) do field :action_logs, type: list_of(:action_log) do
@ -37,5 +60,9 @@ defmodule MobilizonWeb.Schema.AdminType do
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
resolve(&Admin.list_action_logs/3) resolve(&Admin.list_action_logs/3)
end end
field :dashboard, type: :dashboard do
resolve(&Admin.get_dashboard/3)
end
end end
end end

View File

@ -12,7 +12,8 @@ defmodule MobilizonWeb.Schema.EventType do
@desc "An event" @desc "An event"
object :event do object :event do
field(:id, :integer, description: "Internal ID for this event") interfaces([:action_log_object])
field(:id, :id, description: "Internal ID for this event")
field(:uuid, :uuid, description: "The Event UUID") field(:uuid, :uuid, description: "The Event UUID")
field(:url, :string, description: "The ActivityPub Event URL") field(:url, :string, description: "The ActivityPub Event URL")
field(:local, :boolean, description: "Whether the event is local or not") field(:local, :boolean, description: "Whether the event is local or not")
@ -261,8 +262,8 @@ defmodule MobilizonWeb.Schema.EventType do
@desc "Delete an event" @desc "Delete an event"
field :delete_event, :deleted_object do field :delete_event, :deleted_object do
arg(:event_id, non_null(:integer)) arg(:event_id, non_null(:id))
arg(:actor_id, non_null(:integer)) arg(:actor_id, non_null(:id))
resolve(&Event.delete_event/3) resolve(&Event.delete_event/3)
end end

View File

@ -36,7 +36,7 @@ defmodule MobilizonWeb.Schema.Events.FeedTokenType do
object :feed_token_mutations do object :feed_token_mutations do
@desc "Create a Feed Token" @desc "Create a Feed Token"
field :create_feed_token, :feed_token do field :create_feed_token, :feed_token do
arg(:actor_id, :integer) arg(:actor_id, :id)
resolve(&Resolvers.FeedToken.create_feed_token/3) resolve(&Resolvers.FeedToken.create_feed_token/3)
end end

View File

@ -46,16 +46,16 @@ defmodule MobilizonWeb.Schema.Events.ParticipantType do
object :participant_mutations do object :participant_mutations do
@desc "Join an event" @desc "Join an event"
field :join_event, :participant do field :join_event, :participant do
arg(:event_id, non_null(:integer)) arg(:event_id, non_null(:id))
arg(:actor_id, non_null(:integer)) arg(:actor_id, non_null(:id))
resolve(&Resolvers.Event.actor_join_event/3) resolve(&Resolvers.Event.actor_join_event/3)
end end
@desc "Leave an event" @desc "Leave an event"
field :leave_event, :deleted_participant do field :leave_event, :deleted_participant do
arg(:event_id, non_null(:integer)) arg(:event_id, non_null(:id))
arg(:actor_id, non_null(:integer)) arg(:actor_id, non_null(:id))
resolve(&Resolvers.Event.actor_leave_event/3) resolve(&Resolvers.Event.actor_leave_event/3)
end end

View File

@ -3,6 +3,8 @@ defmodule MobilizonWeb.Schema.ReportType do
Schema representation for User Schema representation for User
""" """
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Reports
alias MobilizonWeb.Resolvers.Report alias MobilizonWeb.Resolvers.Report
@ -17,6 +19,14 @@ defmodule MobilizonWeb.Schema.ReportType do
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(:event, :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),
description: "The notes made on the event",
resolve: dataloader(Reports)
)
field(:inserted_at, :datetime, description: "When the report was created")
field(:updated_at, :datetime, description: "When the report was updated")
end end
@desc "A report note object" @desc "A report note object"
@ -24,8 +34,14 @@ defmodule MobilizonWeb.Schema.ReportType do
interfaces([:action_log_object]) interfaces([:action_log_object])
field(:id, :id, description: "The internal ID of the report note") field(:id, :id, description: "The internal ID of the report note")
field(:content, :string, description: "The content of the note") field(:content, :string, description: "The content of the note")
field(:moderator, :actor, description: "The moderator who added the note")
field(:moderator, :actor,
description: "The moderator who added the note",
resolve: dataloader(Reports)
)
field(:report, :report, description: "The report on which this note is added") field(:report, :report, description: "The report on which this note is added")
field(:inserted_at, :datetime, description: "When the report note was created")
end end
@desc "The list of possible statuses for a report object" @desc "The list of possible statuses for a report object"
@ -40,6 +56,7 @@ defmodule MobilizonWeb.Schema.ReportType do
field :reports, list_of(:report) do field :reports, list_of(:report) do
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
arg(:status, :report_status, default_value: :open)
resolve(&Report.list_reports/3) resolve(&Report.list_reports/3)
end end
@ -53,7 +70,7 @@ defmodule MobilizonWeb.Schema.ReportType do
object :report_mutations do object :report_mutations do
@desc "Create a report" @desc "Create a report"
field :create_report, type: :report do field :create_report, type: :report do
arg(:report_content, :string) arg(:content, :string)
arg(:reporter_actor_id, non_null(:id)) arg(:reporter_actor_id, non_null(:id))
arg(:reported_actor_id, non_null(:id)) arg(:reported_actor_id, non_null(:id))
arg(:event_id, :id, default_value: nil) arg(:event_id, :id, default_value: nil)

View File

@ -43,6 +43,14 @@ defmodule MobilizonWeb.Schema.UserType do
resolve: dataloader(Events), resolve: dataloader(Events),
description: "A list of the feed tokens for this user" description: "A list of the feed tokens for this user"
) )
field(:role, :user_role, description: "The role for the user")
end
enum :user_role do
value(:administrator)
value(:moderator)
value(:user)
end end
@desc "Token" @desc "Token"

View File

@ -1,15 +1,15 @@
<h1><%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter, instance: @instance %></h1> <h1><%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter.preferred_username, instance: @instance %></h1>
<% if @report.event do %> <% if @report.event do %>
<p><%= gettext "Event: %{event}", event: @report.event %></p> <p><%= gettext "Event: %{event}", event: @report.event.title %></p>
<% end %> <% end %>
<%= for comment <- @report.comments do %> <%= for comment <- @report.comments do %>
<p><%= gettext "Comment: %{comment}", comment: comment %></p> <p><%= gettext "Comment: %{comment}", comment: comment %></p>
<% end %> <% end %>
<% if @content do %> <% if @report.content do %>
<p><%= gettext "Reason: %{content}", event: @report.content %></p> <p><%= gettext "Reason: %{content}", event: @report.content %></p>
<% end %> <% end %>
<p><%= link "View the report", to: MobilizonWeb.Endpoint.url() <> "/reports/#{@report.id}", target: "_blank" %></p> <p><%= link "View the report", to: moderation_report_url(MobilizonWeb.Endpoint, :index, @report.id), target: "_blank" %></p>

View File

@ -1,19 +1,19 @@
<%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter, instance: @instance %> <%= gettext "New report from %{reporter} on %{instance}", reporter: @report.reporter.preferred_username, instance: @instance %>
-- --
<% if @report.event do %> <% if @report.event do %>
<%= gettext "Event: %{event}", event: @report.event %> <%= gettext "Event: %{event}", event: @report.event.title %>
<% end %> <% end %>
<%= for comment <- @report.comments do %> <%= for comment <- @report.comments do %>
<%= gettext "Comment: %{comment}", comment: comment %> <%= gettext "Comment: %{comment}", comment: comment.text %>
<% end %> <% end %>
<% if @content do %> <% if @report.content do %>
<%= gettext "Reason: %{content}", event: @report.content %> <%= gettext "Reason: %{content}", event: @report.content %>
<% end %> <% end %>
<%= link "View the report", to: MobilizonWeb.Endpoint.url() <> "/reports/#{@report.id}", target: "_blank" %> View the report: <%= moderation_report_url(MobilizonWeb.Endpoint, :index, @report.id) %>

View File

@ -335,9 +335,8 @@ defmodule Mobilizon.Service.ActivityPub do
with {:ok, _} <- Events.delete_event(event), with {:ok, _} <- Events.delete_event(event),
{:ok, activity} <- create_activity(data, local), {:ok, activity} <- create_activity(data, local),
{:ok, object} <- insert_full_object(data),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, object} {:ok, activity, event}
end end
end end
@ -521,7 +520,8 @@ defmodule Mobilizon.Service.ActivityPub do
public = is_public?(activity) public = is_public?(activity)
if public && Mobilizon.CommonConfig.get([:instance, :allow_relay]) do if public && is_delete_activity?(activity) == false &&
Mobilizon.CommonConfig.get([:instance, :allow_relay]) do
Logger.info(fn -> "Relaying #{activity.data["id"]} out" end) Logger.info(fn -> "Relaying #{activity.data["id"]} out" end)
Mobilizon.Service.ActivityPub.Relay.publish(activity) Mobilizon.Service.ActivityPub.Relay.publish(activity)
end end
@ -552,6 +552,9 @@ defmodule Mobilizon.Service.ActivityPub do
end) end)
end end
defp is_delete_activity?(%Activity{data: %{"type" => "Delete"}}), do: true
defp is_delete_activity?(_), do: false
@doc """ @doc """
Publish an activity to a specific inbox Publish an activity to a specific inbox
""" """

View File

@ -164,14 +164,14 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
{:ok, %Report{} = report} <- Reports.create_report(data) do {:ok, %Report{} = report} <- Reports.create_report(data) do
Enum.each(Users.list_moderators(), fn moderator -> Enum.each(Users.list_moderators(), fn moderator ->
moderator moderator
|> Mobilizon.Email.Admin.report(moderator, report) |> Mobilizon.Email.Admin.report(report)
|> Mobilizon.Mailer.deliver_later() |> Mobilizon.Mailer.deliver_later()
end) end)
{:ok, report} {:ok, report}
else else
err -> err ->
Logger.error("Error while inserting a remote comment inside database") Logger.error("Error while inserting report inside database")
Logger.debug(inspect(err)) Logger.debug(inspect(err))
{:error, err} {:error, err}
end end

View File

@ -22,9 +22,19 @@ defmodule Mobilizon.Service.Admin.ActionLogService do
"target_type" => to_string(target.__struct__), "target_type" => to_string(target.__struct__),
"target_id" => target.id, "target_id" => target.id,
"action" => action, "action" => action,
"changes" => Map.from_struct(target) |> Map.take([:status, :uri, :content]) "changes" => stringify_struct(target)
}) do }) do
{:ok, create_action_log} {:ok, create_action_log}
end end
end end
defp stringify_struct(%_{} = struct) do
association_fields = struct.__struct__.__schema__(:associations)
struct
|> Map.from_struct()
|> Map.drop(association_fields ++ [:__meta__])
end
defp stringify_struct(struct), do: struct
end end

31
lib/service/statistics.ex Normal file
View File

@ -0,0 +1,31 @@
defmodule Mobilizon.Service.Statistics do
@moduledoc """
A module that provides cached statistics
"""
alias Mobilizon.Events
alias Mobilizon.Users
def get_cached_value(key) do
case Cachex.fetch(:statistics, key, fn key ->
case create_cache(key) do
value when not is_nil(value) -> {:commit, value}
err -> {:ignore, err}
end
end) do
{status, value} when status in [:ok, :commit] -> value
_err -> nil
end
end
defp create_cache(:local_users) do
Users.count_users()
end
defp create_cache(:local_events) do
Events.count_local_events()
end
defp create_cache(:local_comments) do
Events.count_local_comments()
end
end

View File

@ -1,5 +1,5 @@
# source: http://localhost:4000/api # source: http://localhost:4000/api
# timestamp: Mon Sep 09 2019 11:37:31 GMT+0200 (heure dété dEurope centrale) # timestamp: Mon Sep 09 2019 20:33:17 GMT+0200 (GMT+02:00)
schema { schema {
query: RootQueryType query: RootQueryType
@ -9,7 +9,7 @@ schema {
"""An action log""" """An action log"""
type ActionLog { type ActionLog {
"""The action that was done""" """The action that was done"""
action: String action: ActionLogAction
"""The actor that acted""" """The actor that acted"""
actor: Actor actor: Actor
@ -17,10 +17,23 @@ type ActionLog {
"""Internal ID for this comment""" """Internal ID for this comment"""
id: ID id: ID
"""The time when the action was performed"""
insertedAt: DateTime
"""The object that was acted upon""" """The object that was acted upon"""
object: ActionLogObject object: ActionLogObject
} }
enum ActionLogAction {
EVENT_DELETION
EVENT_UPDATE
NOTE_CREATION
NOTE_DELETION
REPORT_UPDATE_CLOSED
REPORT_UPDATE_OPENED
REPORT_UPDATE_RESOLVED
}
"""The objects that can be in an action log""" """The objects that can be in an action log"""
interface ActionLogObject { interface ActionLogObject {
"""Internal ID for this object""" """Internal ID for this object"""
@ -51,7 +64,7 @@ interface Actor {
followingCount: Int followingCount: Int
"""Internal ID for this actor""" """Internal ID for this actor"""
id: Int id: ID
"""The actors RSA Keys""" """The actors RSA Keys"""
keys: String keys: String
@ -111,7 +124,7 @@ type Address {
"""The geocoordinates for the point where this address is""" """The geocoordinates for the point where this address is"""
geom: Point geom: Point
id: Int id: ID
"""The address's locality""" """The address's locality"""
locality: String locality: String
@ -133,7 +146,7 @@ input AddressInput {
"""The geocoordinates for the point where this address is""" """The geocoordinates for the point where this address is"""
geom: Point geom: Point
id: Int id: ID
"""The address's locality""" """The address's locality"""
locality: String locality: String
@ -185,6 +198,23 @@ type Config {
registrationsOpen: Boolean registrationsOpen: Boolean
} }
type Dashboard {
"""Last public event publish"""
lastPublicEventPublished: Event
"""The number of local comments"""
numberOfComments: Int
"""The number of local events"""
numberOfEvents: Int
"""The number of current opened reports"""
numberOfReports: Int
"""The number of local users"""
numberOfUsers: Int
}
""" """
The `DateTime` scalar type represents a date and time in the UTC The `DateTime` scalar type represents a date and time in the UTC
timezone. The DateTime appears in a JSON response as an ISO8601 formatted timezone. The DateTime appears in a JSON response as an ISO8601 formatted
@ -207,7 +237,7 @@ type DeletedMember {
"""A struct containing the id of the deleted object""" """A struct containing the id of the deleted object"""
type DeletedObject { type DeletedObject {
id: Int id: ID
} }
"""Represents a deleted participant""" """Represents a deleted participant"""
@ -217,7 +247,7 @@ type DeletedParticipant {
} }
"""An event""" """An event"""
type Event { type Event implements ActionLogObject {
"""Who the event is attributed to (often a group)""" """Who the event is attributed to (often a group)"""
attributedTo: Actor attributedTo: Actor
@ -237,7 +267,7 @@ type Event {
endsOn: DateTime endsOn: DateTime
"""Internal ID for this event""" """Internal ID for this event"""
id: Int id: ID
"""Whether the event is local or not""" """Whether the event is local or not"""
local: Boolean local: Boolean
@ -501,7 +531,7 @@ type Group implements Actor {
followingCount: Int followingCount: Int
"""Internal ID for this group""" """Internal ID for this group"""
id: Int id: ID
"""The actors RSA Keys""" """The actors RSA Keys"""
keys: String keys: String
@ -651,7 +681,7 @@ type Person implements Actor {
goingToEvents: [Event] goingToEvents: [Event]
"""Internal ID for this person""" """Internal ID for this person"""
id: Int id: ID
"""The actors RSA Keys""" """The actors RSA Keys"""
keys: String keys: String
@ -763,6 +793,12 @@ type Report implements ActionLogObject {
"""The internal ID of the report""" """The internal ID of the report"""
id: ID id: ID
"""When the report was created"""
insertedAt: DateTime
"""The notes made on the event"""
notes: [ReportNote]
"""The actor that is being reported""" """The actor that is being reported"""
reported: Actor reported: Actor
@ -772,6 +808,9 @@ type Report implements ActionLogObject {
"""Whether the report is still active""" """Whether the report is still active"""
status: ReportStatus status: ReportStatus
"""When the report was updated"""
updatedAt: DateTime
"""The URI of the report""" """The URI of the report"""
uri: String uri: String
} }
@ -784,6 +823,9 @@ type ReportNote implements ActionLogObject {
"""The internal ID of the report note""" """The internal ID of the report note"""
id: ID id: ID
"""When the report note was created"""
insertedAt: DateTime
"""The moderator who added the note""" """The moderator who added the note"""
moderator: Actor moderator: Actor
@ -827,7 +869,7 @@ type RootMutationType {
""" """
picture: PictureInput picture: PictureInput
publishAt: DateTime publishAt: DateTime
status: Int status: EventStatus
"""The list of tags associated to the event""" """The list of tags associated to the event"""
tags: [String] = [""] tags: [String] = [""]
@ -836,7 +878,7 @@ type RootMutationType {
): Event ): Event
"""Create a Feed Token""" """Create a Feed Token"""
createFeedToken(actorId: Int): FeedToken createFeedToken(actorId: ID): FeedToken
"""Create a group""" """Create a group"""
createGroup( createGroup(
@ -851,7 +893,7 @@ type RootMutationType {
banner: PictureInput banner: PictureInput
"""The identity that creates the group""" """The identity that creates the group"""
creatorActorId: Int! creatorActorId: ID!
"""The displayed name for the group""" """The displayed name for the group"""
name: String name: String
@ -884,7 +926,7 @@ type RootMutationType {
): Person ): Person
"""Create a report""" """Create a report"""
createReport(commentsIds: [ID] = [""], eventId: ID, reportContent: String, reportedActorId: ID!, reporterActorId: ID!): Report createReport(commentsIds: [ID] = [""], content: String, eventId: ID, reportedActorId: ID!, reporterActorId: ID!): Report
"""Create a note on a report""" """Create a note on a report"""
createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
@ -893,29 +935,29 @@ type RootMutationType {
createUser(email: String!, password: String!): User createUser(email: String!, password: String!): User
"""Delete an event""" """Delete an event"""
deleteEvent(actorId: Int!, eventId: Int!): DeletedObject deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
"""Delete a feed token""" """Delete a feed token"""
deleteFeedToken(token: String!): DeletedFeedToken deleteFeedToken(token: String!): DeletedFeedToken
"""Delete a group""" """Delete a group"""
deleteGroup(actorId: Int!, groupId: Int!): DeletedObject deleteGroup(actorId: ID!, groupId: ID!): DeletedObject
"""Delete an identity""" """Delete an identity"""
deletePerson(preferredUsername: String!): Person deletePerson(preferredUsername: String!): Person
deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject
"""Join an event""" """Join an event"""
joinEvent(actorId: Int!, eventId: Int!): Participant joinEvent(actorId: ID!, eventId: ID!): Participant
"""Join a group""" """Join a group"""
joinGroup(actorId: Int!, groupId: Int!): Member joinGroup(actorId: ID!, groupId: ID!): Member
"""Leave an event""" """Leave an event"""
leaveEvent(actorId: Int!, eventId: Int!): DeletedParticipant leaveEvent(actorId: ID!, eventId: ID!): DeletedParticipant
"""Leave an event""" """Leave an event"""
leaveGroup(actorId: Int!, groupId: Int!): DeletedMember leaveGroup(actorId: ID!, groupId: ID!): DeletedMember
"""Login an user""" """Login an user"""
login(email: String!, password: String!): Login login(email: String!, password: String!): Login
@ -1019,6 +1061,7 @@ type RootQueryType {
"""Get the instance config""" """Get the instance config"""
config: Config config: Config
dashboard: Dashboard
"""Get an event by uuid""" """Get an event by uuid"""
event(uuid: UUID!): Event event(uuid: UUID!): Event
@ -1054,7 +1097,7 @@ type RootQueryType {
report(id: ID!): Report report(id: ID!): Report
"""Get all reports""" """Get all reports"""
reports(limit: Int = 10, page: Int = 1): [Report] reports(limit: Int = 10, page: Int = 1, status: ReportStatus = OPEN): [Report]
"""Reverse geocode coordinates""" """Reverse geocode coordinates"""
reverseGeocode(latitude: Float!, longitude: Float!): [Address] reverseGeocode(latitude: Float!, longitude: Float!): [Address]
@ -1144,6 +1187,15 @@ type User {
"""The token sent when requesting password token""" """The token sent when requesting password token"""
resetPasswordToken: String resetPasswordToken: String
"""The role for the user"""
role: UserRole
}
enum UserRole {
ADMINISTRATOR
MODERATOR
USER
} }
"""Users list""" """Users list"""

View File

@ -15,6 +15,7 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do
alias Mobilizon.Service.HTTPSignatures.Signature alias Mobilizon.Service.HTTPSignatures.Signature
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
import Mock
setup_all do setup_all do
HTTPoison.start() HTTPoison.start()
@ -111,7 +112,6 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do
end end
describe "deletion" do describe "deletion" do
# TODO: The delete activity it relayed and fetched once again (and then not found /o\)
test "it creates a delete activity and deletes the original event" do test "it creates a delete activity and deletes the original event" do
event = insert(:event) event = insert(:event)
event = Events.get_event_full_by_url!(event.url) event = Events.get_event_full_by_url!(event.url)
@ -124,6 +124,25 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do
assert Events.get_event_by_url(event.url) == nil assert Events.get_event_by_url(event.url) == nil
end end
test "it deletes the original event but only locally if needed" do
with_mock ActivityPub.Utils,
maybe_federate: fn _ -> :ok end,
lazy_put_activity_defaults: fn args -> args end do
event = insert(:event)
event = Events.get_event_full_by_url!(event.url)
{:ok, delete, _} = ActivityPub.delete(event, false)
assert delete.data["type"] == "Delete"
assert delete.data["actor"] == event.organizer_actor.url
assert delete.data["object"] == event.url
assert delete.local == false
assert Events.get_event_by_url(event.url) == nil
assert_called(ActivityPub.Utils.maybe_federate(delete))
end
end
test "it creates a delete activity and deletes the original comment" do test "it creates a delete activity and deletes the original comment" do
comment = insert(:comment) comment = insert(:comment)
comment = Events.get_comment_full_from_url!(comment.url) comment = Events.get_comment_full_from_url!(comment.url)

View File

@ -22,7 +22,7 @@ defmodule Mobilizon.Service.Admin.ActionLogServiceTest do
%ActionLog{ %ActionLog{
target_type: "Elixir.Mobilizon.Reports.Report", target_type: "Elixir.Mobilizon.Reports.Report",
target_id: report_id, target_id: report_id,
action: "update", action: :update,
actor: moderator actor: moderator
}} = log_action(moderator, "update", report) }} = log_action(moderator, "update", report)
end end
@ -35,7 +35,7 @@ defmodule Mobilizon.Service.Admin.ActionLogServiceTest do
%ActionLog{ %ActionLog{
target_type: "Elixir.Mobilizon.Reports.Note", target_type: "Elixir.Mobilizon.Reports.Note",
target_id: note_id, target_id: note_id,
action: "create", action: :create,
actor: moderator actor: moderator
}} = log_action(moderator, "create", report) }} = log_action(moderator, "create", report)
end end

View File

@ -25,7 +25,7 @@ defmodule MobilizonWeb.API.ReportTest do
Reports.report(%{ Reports.report(%{
reporter_actor_id: reporter_id, reporter_actor_id: reporter_id,
reported_actor_id: reported_id, reported_actor_id: reported_id,
report_content: comment, content: comment,
event_id: event_id, event_id: event_id,
comments_ids: [] comments_ids: []
}) })
@ -58,7 +58,7 @@ defmodule MobilizonWeb.API.ReportTest do
Reports.report(%{ Reports.report(%{
reporter_actor_id: reporter_id, reporter_actor_id: reporter_id,
reported_actor_id: reported_id, reported_actor_id: reported_id,
report_content: comment, content: comment,
event_id: nil, event_id: nil,
comments_ids: [comment_1_id, comment_2_id] comments_ids: [comment_1_id, comment_2_id]
}) })
@ -92,7 +92,7 @@ defmodule MobilizonWeb.API.ReportTest do
Reports.report(%{ Reports.report(%{
reporter_actor_id: reporter_id, reporter_actor_id: reporter_id,
reported_actor_id: reported_id, reported_actor_id: reported_id,
report_content: comment, content: comment,
event_id: nil, event_id: nil,
comments_ids: [comment_1_id, comment_2_id], comments_ids: [comment_1_id, comment_2_id],
forward: true forward: true
@ -121,7 +121,7 @@ defmodule MobilizonWeb.API.ReportTest do
Reports.report(%{ Reports.report(%{
reporter_actor_id: reporter_id, reporter_actor_id: reporter_id,
reported_actor_id: reported_id, reported_actor_id: reported_id,
report_content: "This is not a nice thing", content: "This is not a nice thing",
event_id: nil, event_id: nil,
comments_ids: [comment_1_id], comments_ids: [comment_1_id],
forward: true forward: true
@ -147,7 +147,7 @@ defmodule MobilizonWeb.API.ReportTest do
Reports.report(%{ Reports.report(%{
reporter_actor_id: reporter_id, reporter_actor_id: reporter_id,
reported_actor_id: reported_id, reported_actor_id: reported_id,
report_content: "This is not a nice thing", content: "This is not a nice thing",
event_id: nil, event_id: nil,
comments_ids: [comment_1_id], comments_ids: [comment_1_id],
forward: true forward: true

View File

@ -31,8 +31,9 @@ defmodule MobilizonWeb.NodeInfoControllerTest do
end end
test "Get node info", %{conn: conn} do test "Get node info", %{conn: conn} do
# We clear the cache because it might have been initialized by other tests
Cachex.clear(:statistics)
conn = get(conn, node_info_path(conn, :nodeinfo, "2.1")) conn = get(conn, node_info_path(conn, :nodeinfo, "2.1"))
resp = json_response(conn, 200) resp = json_response(conn, 200)
assert resp == %{ assert resp == %{
@ -44,7 +45,7 @@ defmodule MobilizonWeb.NodeInfoControllerTest do
"protocols" => ["activitypub"], "protocols" => ["activitypub"],
"services" => %{"inbound" => [], "outbound" => ["atom1.0"]}, "services" => %{"inbound" => [], "outbound" => ["atom1.0"]},
"software" => %{ "software" => %{
"name" => "mobilizon", "name" => "Mobilizon",
"version" => Keyword.get(@instance, :version), "version" => Keyword.get(@instance, :version),
"repository" => Keyword.get(@instance, :repository) "repository" => Keyword.get(@instance, :repository)
}, },

View File

@ -3,6 +3,7 @@ defmodule MobilizonWeb.Resolvers.AdminResolverTest do
use MobilizonWeb.ConnCase use MobilizonWeb.ConnCase
import Mobilizon.Factory import Mobilizon.Factory
alias Mobilizon.Events.Event
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Reports.{Report, Note} alias Mobilizon.Reports.{Report, Note}
@ -62,21 +63,60 @@ defmodule MobilizonWeb.Resolvers.AdminResolverTest do
assert json_response(res, 200)["data"]["actionLogs"] == [ assert json_response(res, 200)["data"]["actionLogs"] == [
%{ %{
"action" => "report_update_resolved", "action" => "NOTE_DELETION",
"actor" => %{"preferredUsername" => moderator_2.preferred_username},
"object" => %{"content" => @note_content}
},
%{
"action" => "NOTE_CREATION",
"actor" => %{"preferredUsername" => moderator_2.preferred_username},
"object" => %{"content" => @note_content}
},
%{
"action" => "REPORT_UPDATE_RESOLVED",
"actor" => %{"preferredUsername" => moderator.preferred_username}, "actor" => %{"preferredUsername" => moderator.preferred_username},
"object" => %{"id" => to_string(report.id), "status" => "RESOLVED"} "object" => %{"id" => to_string(report.id), "status" => "RESOLVED"}
},
%{
"action" => "note_creation",
"actor" => %{"preferredUsername" => moderator_2.preferred_username},
"object" => %{"content" => @note_content}
},
%{
"action" => "note_deletion",
"actor" => %{"preferredUsername" => moderator_2.preferred_username},
"object" => %{"content" => @note_content}
} }
] ]
end end
end end
describe "Resolver: Get the dashboard statistics" do
test "get_dashboard/3 gets dashboard information", %{conn: conn} do
%Event{title: title} = insert(:event)
%User{} = user_admin = insert(:user, role: :administrator)
query = """
{
dashboard {
lastPublicEventPublished {
title
}
numberOfUsers,
numberOfComments,
numberOfEvents,
numberOfReports
}
}
"""
res =
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs"))
assert json_response(res, 200)["errors"] |> hd |> Map.get("message") ==
"You need to be logged-in and an administrator to access dashboard statistics"
res =
conn
|> auth_conn(user_admin)
|> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["dashboard"]["lastPublicEventPublished"]["title"] ==
title
end
end
end end

View File

@ -731,7 +731,7 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["deleteEvent"]["id"] == event.id assert json_response(res, 200)["data"]["deleteEvent"]["id"] == to_string(event.id)
res = res =
conn conn
@ -815,6 +815,72 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
assert hd(json_response(res, 200)["errors"])["message"] =~ "cannot delete" assert hd(json_response(res, 200)["errors"])["message"] =~ "cannot delete"
end end
test "delete_event/3 allows a event being deleted by a moderator and creates a entry in actionLogs",
%{
conn: conn,
user: _user,
actor: _actor
} do
user_moderator = insert(:user, role: :moderator)
actor_moderator = insert(:actor, user: user_moderator)
actor2 = insert(:actor)
event = insert(:event, organizer_actor: actor2)
mutation = """
mutation {
deleteEvent(
actor_id: #{actor_moderator.id},
event_id: #{event.id}
) {
id
}
}
"""
res =
conn
|> auth_conn(user_moderator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["data"]["deleteEvent"]["id"] == to_string(event.id)
query = """
{
actionLogs {
action,
actor {
preferredUsername
},
object {
... on Report {
id,
status
},
... on ReportNote {
content
}
... on Event {
id,
title
}
}
}
}
"""
res =
conn
|> auth_conn(user_moderator)
|> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs"))
assert hd(json_response(res, 200)["data"]["actionLogs"]) == %{
"action" => "EVENT_DELETION",
"actor" => %{"preferredUsername" => actor_moderator.preferred_username},
"object" => %{"title" => event.title, "id" => to_string(event.id)}
}
end
test "list_related_events/3 should give related events", %{ test "list_related_events/3 should give related events", %{
conn: conn, conn: conn,
actor: actor actor: actor

View File

@ -43,7 +43,8 @@ defmodule MobilizonWeb.Resolvers.FeedTokenResolverTest do
assert json_response(res, 200)["data"]["createFeedToken"]["user"]["id"] == assert json_response(res, 200)["data"]["createFeedToken"]["user"]["id"] ==
to_string(user.id) to_string(user.id)
assert json_response(res, 200)["data"]["createFeedToken"]["actor"]["id"] == actor2.id assert json_response(res, 200)["data"]["createFeedToken"]["actor"]["id"] ==
to_string(actor2.id)
# The token is present for the user # The token is present for the user
query = """ query = """
@ -209,8 +210,12 @@ defmodule MobilizonWeb.Resolvers.FeedTokenResolverTest do
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["deleteFeedToken"]["user"]["id"] == user.id
assert json_response(res, 200)["data"]["deleteFeedToken"]["actor"]["id"] == actor.id assert json_response(res, 200)["data"]["deleteFeedToken"]["user"]["id"] ==
to_string(user.id)
assert json_response(res, 200)["data"]["deleteFeedToken"]["actor"]["id"] ==
to_string(actor.id)
query = """ query = """
{ {

View File

@ -163,7 +163,7 @@ defmodule MobilizonWeb.Resolvers.GroupResolverTest do
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["deleteGroup"]["id"] == group.id assert json_response(res, 200)["data"]["deleteGroup"]["id"] == to_string(group.id)
res = res =
conn conn

View File

@ -40,8 +40,8 @@ defmodule MobilizonWeb.Resolvers.MemberResolverTest do
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinGroup"]["role"] == "not_approved" assert json_response(res, 200)["data"]["joinGroup"]["role"] == "not_approved"
assert json_response(res, 200)["data"]["joinGroup"]["parent"]["id"] == group.id assert json_response(res, 200)["data"]["joinGroup"]["parent"]["id"] == to_string(group.id)
assert json_response(res, 200)["data"]["joinGroup"]["actor"]["id"] == actor.id assert json_response(res, 200)["data"]["joinGroup"]["actor"]["id"] == to_string(actor.id)
mutation = """ mutation = """
mutation { mutation {
@ -167,8 +167,8 @@ defmodule MobilizonWeb.Resolvers.MemberResolverTest do
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["leaveGroup"]["parent"]["id"] == group.id assert json_response(res, 200)["data"]["leaveGroup"]["parent"]["id"] == to_string(group.id)
assert json_response(res, 200)["data"]["leaveGroup"]["actor"]["id"] == actor.id assert json_response(res, 200)["data"]["leaveGroup"]["actor"]["id"] == to_string(actor.id)
end end
test "leave_group/3 should check if the member is the only administrator", %{ test "leave_group/3 should check if the member is the only administrator", %{

View File

@ -50,8 +50,8 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "participant" assert json_response(res, 200)["data"]["joinEvent"]["role"] == "participant"
assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == event.id assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == actor.id assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == to_string(actor.id)
mutation = """ mutation = """
mutation { mutation {
@ -119,7 +119,7 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] == assert hd(json_response(res, 200)["errors"])["message"] ==
"Event with this ID 1042 doesn't exist" "Event with this ID \"1042\" doesn't exist"
end end
test "actor_leave_event/3 should delete a participant from an event", %{ test "actor_leave_event/3 should delete a participant from an event", %{
@ -153,8 +153,10 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["leaveEvent"]["event"]["id"] == event.id assert json_response(res, 200)["data"]["leaveEvent"]["event"]["id"] == to_string(event.id)
assert json_response(res, 200)["data"]["leaveEvent"]["actor"]["id"] == participant.actor.id
assert json_response(res, 200)["data"]["leaveEvent"]["actor"]["id"] ==
to_string(participant.actor.id)
query = """ query = """
{ {

View File

@ -21,7 +21,7 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
reporter_actor_id: #{reporter.id}, reporter_actor_id: #{reporter.id},
reported_actor_id: #{reported.id}, reported_actor_id: #{reported.id},
event_id: #{event.id}, event_id: #{event.id},
report_content: "This is an issue" content: "This is an issue"
) { ) {
content, content,
reporter { reporter {
@ -43,8 +43,10 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["createReport"]["content"] == "This is an issue" assert json_response(res, 200)["data"]["createReport"]["content"] == "This is an issue"
assert json_response(res, 200)["data"]["createReport"]["status"] == "OPEN" assert json_response(res, 200)["data"]["createReport"]["status"] == "OPEN"
assert json_response(res, 200)["data"]["createReport"]["event"]["id"] == event.id assert json_response(res, 200)["data"]["createReport"]["event"]["id"] == to_string(event.id)
assert json_response(res, 200)["data"]["createReport"]["reporter"]["id"] == reporter.id
assert json_response(res, 200)["data"]["createReport"]["reporter"]["id"] ==
to_string(reporter.id)
end end
test "create_report/3 without being connected doesn't create any report", %{conn: conn} do test "create_report/3 without being connected doesn't create any report", %{conn: conn} do
@ -55,7 +57,7 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
createReport( createReport(
reported_actor_id: #{reported.id}, reported_actor_id: #{reported.id},
reporter_actor_id: 5, reporter_actor_id: 5,
report_content: "This is an issue" content: "This is an issue"
) { ) {
content content
} }
@ -109,7 +111,7 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
assert json_response(res, 200)["data"]["updateReportStatus"]["status"] == "RESOLVED" assert json_response(res, 200)["data"]["updateReportStatus"]["status"] == "RESOLVED"
assert json_response(res, 200)["data"]["updateReportStatus"]["reporter"]["id"] == assert json_response(res, 200)["data"]["updateReportStatus"]["reporter"]["id"] ==
report.reporter.id to_string(report.reporter.id)
end end
test "create_report/3 without being connected doesn't create any report", %{conn: conn} do test "create_report/3 without being connected doesn't create any report", %{conn: conn} do
@ -172,9 +174,14 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
test "get a list of reports", %{conn: conn} do test "get a list of reports", %{conn: conn} do
%User{} = user_moderator = insert(:user, role: :moderator) %User{} = user_moderator = insert(:user, role: :moderator)
%Report{id: report_1_id} = insert(:report) # Report don't hold millisecond information so we need to wait a bit
%Report{id: report_2_id} = insert(:report) # between each insert to keep order
%Report{id: report_3_id} = insert(:report) %Report{id: report_1_id} = insert(:report, content: "My content 1")
Process.sleep(1000)
%Report{id: report_2_id} = insert(:report, content: "My content 2")
Process.sleep(1000)
%Report{id: report_3_id} = insert(:report, content: "My content 3")
%Report{} = insert(:report, status: :closed)
query = """ query = """
{ {
@ -182,7 +189,9 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
id, id,
reported { reported {
preferredUsername preferredUsername
} },
content,
updatedAt
} }
} }
""" """
@ -196,7 +205,7 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
assert json_response(res, 200)["data"]["reports"] assert json_response(res, 200)["data"]["reports"]
|> Enum.map(fn report -> Map.get(report, "id") end) == |> Enum.map(fn report -> Map.get(report, "id") end) ==
Enum.map([report_1_id, report_2_id, report_3_id], &to_string/1) Enum.map([report_3_id, report_2_id, report_1_id], &to_string/1)
query = """ query = """
{ {
@ -360,7 +369,9 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["deleteReportNote"]["id"] == report_note_id
assert json_response(res, 200)["data"]["deleteReportNote"]["id"] ==
to_string(report_note_id)
end end
end end
end end