From efc52004def6f3264f3149a5ef92b0a216b803bc Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Mon, 9 Sep 2019 09:31:08 +0200 Subject: [PATCH] Add admin dashboard, event reporting, moderation report screens, moderation log Close #156 and #158 Signed-off-by: Thomas Citharel --- js/src/App.vue | 9 +- js/src/apollo/user.ts | 5 +- js/src/components/Event/EventCard.vue | 2 +- js/src/components/Event/EventFullDate.vue | 11 +- js/src/components/NavBar.vue | 13 +- js/src/components/Report/ReportCard.vue | 45 +++ js/src/components/Report/ReportModal.vue | 101 +++++++ js/src/constants.ts | 1 + js/src/filters/datetime.ts | 19 ++ js/src/filters/index.ts | 9 + js/src/graphql/actor.ts | 2 +- js/src/graphql/admin.ts | 19 ++ js/src/graphql/auth.ts | 3 +- js/src/graphql/event.ts | 22 +- js/src/graphql/feed_tokens.ts | 2 +- js/src/graphql/report.ts | 161 +++++++++++ js/src/graphql/user.ts | 5 +- js/src/main.ts | 2 + js/src/router/admin.ts | 16 ++ js/src/router/index.ts | 6 + js/src/router/moderation.ts | 34 +++ js/src/types/admin.model.ts | 9 + js/src/types/current-user.model.ts | 7 + js/src/types/report.model.ts | 47 +++ js/src/utils/auth.ts | 7 +- js/src/views/Admin/Dashboard.vue | 73 +++++ js/src/views/Event/Event.vue | 58 +++- js/src/views/Moderation/Logs.vue | 80 ++++++ js/src/views/Moderation/Report.vue | 271 ++++++++++++++++++ js/src/views/Moderation/ReportList.vue | 90 ++++++ js/src/views/User/Login.vue | 1 + lib/mobilizon/admin.ex | 3 +- lib/mobilizon/admin/action_log.ex | 12 +- lib/mobilizon/application.ex | 15 + lib/mobilizon/events/event_options.ex | 1 + lib/mobilizon/reports.ex | 16 +- lib/mobilizon/reports/note.ex | 1 + lib/mobilizon/reports/report.ex | 4 +- lib/mobilizon_web/api/events.ex | 9 + lib/mobilizon_web/api/reports.ex | 16 +- lib/mobilizon_web/api/utils.ex | 4 +- .../controllers/node_info_controller.ex | 10 +- lib/mobilizon_web/resolvers/admin.ex | 87 +++++- lib/mobilizon_web/resolvers/event.ex | 36 ++- lib/mobilizon_web/resolvers/group.ex | 12 +- lib/mobilizon_web/resolvers/report.ex | 12 +- lib/mobilizon_web/router.ex | 3 + lib/mobilizon_web/schema.ex | 7 +- lib/mobilizon_web/schema/actor.ex | 2 +- lib/mobilizon_web/schema/actors/group.ex | 10 +- lib/mobilizon_web/schema/actors/member.ex | 8 +- lib/mobilizon_web/schema/actors/person.ex | 2 +- lib/mobilizon_web/schema/address.ex | 4 +- lib/mobilizon_web/schema/admin.ex | 29 +- lib/mobilizon_web/schema/event.ex | 7 +- lib/mobilizon_web/schema/events/feed_token.ex | 2 +- .../schema/events/participant.ex | 8 +- lib/mobilizon_web/schema/report.ex | 21 +- lib/mobilizon_web/schema/user.ex | 8 + .../templates/email/report.html.eex | 8 +- .../templates/email/report.text.eex | 10 +- lib/service/activity_pub/activity_pub.ex | 9 +- lib/service/activity_pub/utils.ex | 4 +- lib/service/admin/action_log_service.ex | 12 +- lib/service/statistics.ex | 31 ++ schema.graphql | 94 ++++-- .../activity_pub/activity_pub_test.exs | 21 +- .../service/admin/action_log_service_test.exs | 4 +- test/mobilizon_web/api/report_test.exs | 10 +- .../controllers/nodeinfo_controller_test.exs | 5 +- .../resolvers/admin_resolver_test.exs | 62 +++- .../resolvers/event_resolver_test.exs | 68 ++++- .../resolvers/feed_token_resolver_test.exs | 11 +- .../resolvers/group_resolver_test.exs | 2 +- .../resolvers/member_resolver_test.exs | 8 +- .../resolvers/participant_resolver_test.exs | 12 +- .../resolvers/report_resolver_test.exs | 33 ++- 77 files changed, 1682 insertions(+), 201 deletions(-) create mode 100644 js/src/components/Report/ReportCard.vue create mode 100644 js/src/components/Report/ReportModal.vue create mode 100644 js/src/filters/datetime.ts create mode 100644 js/src/filters/index.ts create mode 100644 js/src/graphql/admin.ts create mode 100644 js/src/graphql/report.ts create mode 100644 js/src/router/admin.ts create mode 100644 js/src/router/moderation.ts create mode 100644 js/src/types/admin.model.ts create mode 100644 js/src/types/report.model.ts create mode 100644 js/src/views/Admin/Dashboard.vue create mode 100644 js/src/views/Moderation/Logs.vue create mode 100644 js/src/views/Moderation/Report.vue create mode 100644 js/src/views/Moderation/ReportList.vue create mode 100644 lib/service/statistics.ex diff --git a/js/src/App.vue b/js/src/App.vue index 73839d96e..cf47cef3b 100644 --- a/js/src/App.vue +++ b/js/src/App.vue @@ -11,7 +11,7 @@ diff --git a/js/src/components/Report/ReportCard.vue b/js/src/components/Report/ReportCard.vue new file mode 100644 index 000000000..e0bc9d46d --- /dev/null +++ b/js/src/components/Report/ReportCard.vue @@ -0,0 +1,45 @@ + + + \ No newline at end of file diff --git a/js/src/components/Report/ReportModal.vue b/js/src/components/Report/ReportModal.vue new file mode 100644 index 000000000..5a25e6770 --- /dev/null +++ b/js/src/components/Report/ReportModal.vue @@ -0,0 +1,101 @@ + + + + \ No newline at end of file diff --git a/js/src/constants.ts b/js/src/constants.ts index 79dbe270f..e815c8b18 100644 --- a/js/src/constants.ts +++ b/js/src/constants.ts @@ -3,3 +3,4 @@ export const AUTH_REFRESH_TOKEN = 'auth-refresh-token'; export const AUTH_USER_ID = 'auth-user-id'; export const AUTH_USER_EMAIL = 'auth-user-email'; export const AUTH_USER_ACTOR = 'auth-user-actor'; +export const AUTH_USER_ROLE = 'auth-user-role'; diff --git a/js/src/filters/datetime.ts b/js/src/filters/datetime.ts new file mode 100644 index 000000000..89b149476 --- /dev/null +++ b/js/src/filters/datetime.ts @@ -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 }; diff --git a/js/src/filters/index.ts b/js/src/filters/index.ts new file mode 100644 index 000000000..89a7267a5 --- /dev/null +++ b/js/src/filters/index.ts @@ -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); + }, +}; diff --git a/js/src/graphql/actor.ts b/js/src/graphql/actor.ts index 7dc92ca20..88d99de49 100644 --- a/js/src/graphql/actor.ts +++ b/js/src/graphql/actor.ts @@ -177,7 +177,7 @@ query($name:String!) { export const CREATE_GROUP = gql` mutation CreateGroup( - $creatorActorId: Int!, + $creatorActorId: ID!, $preferredUsername: String!, $name: String!, $summary: String, diff --git a/js/src/graphql/admin.ts b/js/src/graphql/admin.ts new file mode 100644 index 000000000..d646bb565 --- /dev/null +++ b/js/src/graphql/admin.ts @@ -0,0 +1,19 @@ +import gql from 'graphql-tag'; + +export const DASHBOARD = gql` + query { + dashboard { + lastPublicEventPublished { + title, + picture { + alt + url + }, + }, + numberOfUsers, + numberOfEvents, + numberOfComments, + numberOfReports + } + } + `; diff --git a/js/src/graphql/auth.ts b/js/src/graphql/auth.ts index 580236196..438c46c18 100644 --- a/js/src/graphql/auth.ts +++ b/js/src/graphql/auth.ts @@ -7,7 +7,8 @@ mutation Login($email: String!, $password: String!) { refreshToken, user { id, - email + email, + role } }, } diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts index 0c4af8f67..d6e98cff7 100644 --- a/js/src/graphql/event.ts +++ b/js/src/graphql/event.ts @@ -70,8 +70,8 @@ export const FETCH_EVENT = gql` }, publishAt, category, - online_address, - phone_address, + onlineAddress, + phoneAddress, physicalAddress { ${physicalAddressQuery} } @@ -218,8 +218,8 @@ export const CREATE_EVENT = gql` }, publishAt, category, - online_address, - phone_address, + onlineAddress, + phoneAddress, physicalAddress { ${physicalAddressQuery} }, @@ -240,7 +240,7 @@ export const EDIT_EVENT = gql` $description: String, $beginsOn: DateTime, $endsOn: DateTime, - $status: Int, + $status: EventStatus, $visibility: EventVisibility $tags: [String], $picture: PictureInput, @@ -280,8 +280,8 @@ export const EDIT_EVENT = gql` }, publishAt, category, - online_address, - phone_address, + onlineAddress, + phoneAddress, physicalAddress { ${physicalAddressQuery} }, @@ -296,7 +296,7 @@ export const EDIT_EVENT = gql` `; export const JOIN_EVENT = gql` - mutation JoinEvent($eventId: Int!, $actorId: Int!) { + mutation JoinEvent($eventId: ID!, $actorId: ID!) { joinEvent( eventId: $eventId, actorId: $actorId @@ -307,7 +307,7 @@ export const JOIN_EVENT = gql` `; export const LEAVE_EVENT = gql` - mutation LeaveEvent($eventId: Int!, $actorId: Int!) { + mutation LeaveEvent($eventId: ID!, $actorId: ID!) { leaveEvent( eventId: $eventId, actorId: $actorId @@ -320,9 +320,9 @@ export const LEAVE_EVENT = gql` `; export const DELETE_EVENT = gql` - mutation DeleteEvent($id: Int!, $actorId: Int!) { + mutation DeleteEvent($eventId: ID!, $actorId: ID!) { deleteEvent( - eventId: $id, + eventId: $eventId, actorId: $actorId ) { id diff --git a/js/src/graphql/feed_tokens.ts b/js/src/graphql/feed_tokens.ts index e43c11ca9..eeb17f5cd 100644 --- a/js/src/graphql/feed_tokens.ts +++ b/js/src/graphql/feed_tokens.ts @@ -12,7 +12,7 @@ query { }`; export const CREATE_FEED_TOKEN_ACTOR = gql` -mutation createFeedToken($actor_id: Int!) { +mutation createFeedToken($actor_id: ID!) { createFeedToken(actorId: $actor_id) { token, actor { diff --git a/js/src/graphql/report.ts b/js/src/graphql/report.ts new file mode 100644 index 000000000..9cd58c2f1 --- /dev/null +++ b/js/src/graphql/report.ts @@ -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 + } + } + `; diff --git a/js/src/graphql/user.ts b/js/src/graphql/user.ts index b23b5da32..5365ab600 100644 --- a/js/src/graphql/user.ts +++ b/js/src/graphql/user.ts @@ -31,12 +31,13 @@ query { id, email, isLoggedIn, + role } } `; export const UPDATE_CURRENT_USER_CLIENT = gql` -mutation UpdateCurrentUser($id: Int!, $email: String!, $isLoggedIn: Boolean!) { - updateCurrentUser(id: $id, email: $email, isLoggedIn: $isLoggedIn) @client +mutation UpdateCurrentUser($id: Int!, $email: String!, $isLoggedIn: Boolean!, $role: UserRole!) { + updateCurrentUser(id: $id, email: $email, isLoggedIn: $isLoggedIn, role: $role) @client } `; diff --git a/js/src/main.ts b/js/src/main.ts index 25a5e6068..7878e9605 100644 --- a/js/src/main.ts +++ b/js/src/main.ts @@ -7,6 +7,7 @@ import App from '@/App.vue'; import router from '@/router'; import { apolloProvider } from './vue-apollo'; import { NotifierPlugin } from '@/plugins/notifier'; +import filters from '@/filters'; const translations = require('@/i18n/translations.json'); @@ -14,6 +15,7 @@ Vue.config.productionTip = false; Vue.use(Buefy); Vue.use(NotifierPlugin); +Vue.use(filters); const language = (window.navigator as any).userLanguage || window.navigator.language; diff --git a/js/src/router/admin.ts b/js/src/router/admin.ts new file mode 100644 index 000000000..58ed37e64 --- /dev/null +++ b/js/src/router/admin.ts @@ -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 }, + }, +]; diff --git a/js/src/router/index.ts b/js/src/router/index.ts index cca5e3726..44534e842 100644 --- a/js/src/router/index.ts +++ b/js/src/router/index.ts @@ -5,9 +5,11 @@ import Home from '@/views/Home.vue'; import { UserRouteName, userRoutes } from './user'; import { EventRouteName, eventRoutes } from '@/router/event'; import { ActorRouteName, actorRoutes, MyAccountRouteName } from '@/router/actor'; +import { AdminRouteName, adminRoutes } from '@/router/admin'; import { ErrorRouteName, errorRoutes } from '@/router/error'; import { authGuardIfNeeded } from '@/router/guards/auth-guard'; import Search from '@/views/Search.vue'; +import { ModerationRouteName, moderationRoutes } from '@/router/moderation'; Vue.use(Router); @@ -35,6 +37,8 @@ export const RouteName = { ...EventRouteName, ...ActorRouteName, ...MyAccountRouteName, + ...AdminRouteName, + ...ModerationRouteName, ...ErrorRouteName, }; @@ -46,6 +50,8 @@ const router = new Router({ ...userRoutes, ...eventRoutes, ...actorRoutes, + ...adminRoutes, + ...moderationRoutes, ...errorRoutes, { path: '/search/:searchTerm/:searchType?', diff --git a/js/src/router/moderation.ts b/js/src/router/moderation.ts new file mode 100644 index 000000000..528345242 --- /dev/null +++ b/js/src/router/moderation.ts @@ -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 }, + }, +]; diff --git a/js/src/types/admin.model.ts b/js/src/types/admin.model.ts new file mode 100644 index 000000000..226b2fb59 --- /dev/null +++ b/js/src/types/admin.model.ts @@ -0,0 +1,9 @@ +import { IEvent } from '@/types/event.model'; + +export interface IDashboard { + lastPublicEventPublished: IEvent; + numberOfUsers: number; + numberOfEvents: number; + numberOfComments: number; + numberOfReports: number; +} diff --git a/js/src/types/current-user.model.ts b/js/src/types/current-user.model.ts index 0c1f6277b..0bafaac51 100644 --- a/js/src/types/current-user.model.ts +++ b/js/src/types/current-user.model.ts @@ -1,5 +1,12 @@ +export enum ICurrentUserRole { + USER = 'USER', + MODERATOR = 'MODERATOR', + ADMINISTRATOR = 'ADMINISTRATOR', +} + export interface ICurrentUser { id: number; email: string; isLoggedIn: boolean; + role: ICurrentUserRole; } diff --git a/js/src/types/report.model.ts b/js/src/types/report.model.ts new file mode 100644 index 000000000..b1cea059f --- /dev/null +++ b/js/src/types/report.model.ts @@ -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; +} diff --git a/js/src/utils/auth.ts b/js/src/utils/auth.ts index 29698bf7a..75ec0a835 100644 --- a/js/src/utils/auth.ts +++ b/js/src/utils/auth.ts @@ -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 { UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user'; import { onLogout } from '@/vue-apollo'; import ApolloClient from 'apollo-client'; +import { ICurrentUserRole } from '@/types/current-user.model'; export function saveUserData(obj: ILogin) { localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`); localStorage.setItem(AUTH_USER_EMAIL, obj.user.email); + localStorage.setItem(AUTH_USER_ROLE, obj.user.role); saveTokenData(obj); } @@ -17,7 +19,7 @@ export function saveTokenData(obj: IToken) { } 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); } } @@ -29,6 +31,7 @@ export function logout(apollo: ApolloClient) { id: null, email: null, isLoggedIn: false, + role: ICurrentUserRole.USER, }, }); diff --git a/js/src/views/Admin/Dashboard.vue b/js/src/views/Admin/Dashboard.vue new file mode 100644 index 000000000..7628d1e66 --- /dev/null +++ b/js/src/views/Admin/Dashboard.vue @@ -0,0 +1,73 @@ + + \ No newline at end of file diff --git a/js/src/views/Event/Event.vue b/js/src/views/Event/Event.vue index 658bdaa73..eb21b4a87 100644 --- a/js/src/views/Event/Event.vue +++ b/js/src/views/Event/Event.vue @@ -53,8 +53,8 @@

@@ -241,6 +249,9 @@ import BIcon from 'buefy/src/components/icon/Icon.vue'; import EventCard from '@/components/Event/EventCard.vue'; import EventFullDate from '@/components/Event/EventFullDate.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({ components: { @@ -249,6 +260,7 @@ import ActorLink from '@/components/Account/ActorLink.vue'; EventCard, BIcon, DateCalendarIcon, + ReportModal, // tslint:disable:space-in-parens 'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'), // tslint:enable @@ -274,6 +286,7 @@ export default class Event extends Vue { loggedPerson!: IPerson; validationSent: boolean = false; showMap: boolean = false; + isReportModalActive: boolean = false; EventVisibility = EventVisibility; @@ -285,24 +298,47 @@ export default class Event extends Vue { type: 'is-danger', title: this.$gettext('Delete event'), message: this.$gettextInterpolate( - `${prefix}` + - 'Are you sure you want to delete this event? This action cannot be reverted.

' + - 'To confirm, type your event title "%{eventTitle}"', - { participants: this.event.participants.length, eventTitle: this.event.title }, + `${prefix}` + + 'Are you sure you want to delete this event? This action cannot be reverted.

' + + 'To confirm, type your event title "%{eventTitle}"', + { participants: this.event.participants.length, eventTitle: this.event.title }, ), confirmText: this.$gettextInterpolate( - 'Delete %{eventTitle}', - { eventTitle: this.event.title }, + 'Delete %{eventTitle}', + { eventTitle: this.event.title }, ), inputAttrs: { placeholder: this.event.title, pattern: this.event.title, }, - onConfirm: () => this.deleteEvent(), }); } + async reportEvent(content: string, forward: boolean) { + this.isReportModalActive = false; + const eventTitle = this.event.title; + try { + await this.$apollo.mutate({ + 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() { try { await this.$apollo.mutate<{ joinEvent: IParticipant }>({ @@ -408,7 +444,7 @@ export default class Event extends Vue { await this.$apollo.mutate({ mutation: DELETE_EVENT, variables: { - id: this.event.id, + eventId: this.event.id, actorId: this.loggedPerson.id, }, }); diff --git a/js/src/views/Moderation/Logs.vue b/js/src/views/Moderation/Logs.vue new file mode 100644 index 000000000..aae6f4c9e --- /dev/null +++ b/js/src/views/Moderation/Logs.vue @@ -0,0 +1,80 @@ +import {ReportStatusEnum} from "@/types/report.model"; + + + \ No newline at end of file diff --git a/js/src/views/Moderation/Report.vue b/js/src/views/Moderation/Report.vue new file mode 100644 index 000000000..07c84bce4 --- /dev/null +++ b/js/src/views/Moderation/Report.vue @@ -0,0 +1,271 @@ + + + \ No newline at end of file diff --git a/js/src/views/Moderation/ReportList.vue b/js/src/views/Moderation/ReportList.vue new file mode 100644 index 000000000..5aa09f15a --- /dev/null +++ b/js/src/views/Moderation/ReportList.vue @@ -0,0 +1,90 @@ +import {ReportStatusEnum} from "@/types/report.model"; + + + \ No newline at end of file diff --git a/js/src/views/User/Login.vue b/js/src/views/User/Login.vue index 8778b5ab2..49ae7d5c4 100644 --- a/js/src/views/User/Login.vue +++ b/js/src/views/User/Login.vue @@ -143,6 +143,7 @@ export default class Login extends Vue { id: data.login.user.id, email: this.credentials.email, isLoggedIn: true, + role: data.login.user.role, }, }); diff --git a/lib/mobilizon/admin.ex b/lib/mobilizon/admin.ex index fba5ee050..33c2286ea 100644 --- a/lib/mobilizon/admin.ex +++ b/lib/mobilizon/admin.ex @@ -22,7 +22,8 @@ defmodule Mobilizon.Admin do def list_action_logs(page \\ nil, limit \\ nil) do from( r in ActionLog, - preload: [:actor] + preload: [:actor], + order_by: [desc: :id] ) |> paginate(page, limit) |> Repo.all() diff --git a/lib/mobilizon/admin/action_log.ex b/lib/mobilizon/admin/action_log.ex index 2b3f80f7e..62977e4ea 100644 --- a/lib/mobilizon/admin/action_log.ex +++ b/lib/mobilizon/admin/action_log.ex @@ -1,3 +1,11 @@ +import EctoEnum + +defenum(Mobilizon.Admin.ActionLogAction, [ + "update", + "create", + "delete" +]) + defmodule Mobilizon.Admin.ActionLog do @moduledoc """ ActionLog entity schema @@ -5,11 +13,13 @@ defmodule Mobilizon.Admin.ActionLog do use Ecto.Schema import Ecto.Changeset alias Mobilizon.Actors.Actor + alias Mobilizon.Admin.ActionLogAction + @timestamps_opts [type: :utc_datetime] @required_attrs [:action, :target_type, :target_id, :changes, :actor_id] schema "admin_action_logs" do - field(:action, :string) + field(:action, ActionLogAction) field(:target_type, :string) field(:target_id, :integer) field(:changes, :map) diff --git a/lib/mobilizon/application.ex b/lib/mobilizon/application.ex index 22bf699d8..294831bba 100644 --- a/lib/mobilizon/application.ex +++ b/lib/mobilizon/application.ex @@ -54,6 +54,21 @@ defmodule Mobilizon.Application do ], id: :cache_ics ), + worker( + Cachex, + [ + :statistics, + [ + limit: 10, + expiration: + expiration( + default: :timer.minutes(60), + interval: :timer.seconds(60) + ) + ] + ], + id: :cache_statistics + ), worker( Cachex, [ diff --git a/lib/mobilizon/events/event_options.ex b/lib/mobilizon/events/event_options.ex index c6a02fafd..7c552d822 100644 --- a/lib/mobilizon/events/event_options.ex +++ b/lib/mobilizon/events/event_options.ex @@ -42,6 +42,7 @@ defmodule Mobilizon.Events.EventOptions do } @primary_key false + @derive Jason.Encoder embedded_schema do field(:maximum_attendee_capacity, :integer) field(:remaining_attendee_capacity, :integer) diff --git a/lib/mobilizon/reports.ex b/lib/mobilizon/reports.ex index 1bfaac7ac..6a00dc70e 100644 --- a/lib/mobilizon/reports.ex +++ b/lib/mobilizon/reports.ex @@ -30,16 +30,28 @@ defmodule Mobilizon.Reports do """ @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( r in Report, - preload: [:reported, :reporter, :manager, :event, :comments, :notes] + preload: [:reported, :reporter, :manager, :event, :comments, :notes], + where: r.status == ^status ) |> paginate(page, limit) |> sort(sort, direction) |> Repo.all() end + def count_opened_reports() do + query = from(r in Report, where: r.status == ^:open) + Repo.aggregate(query, :count, :id) + end + @doc """ Gets a single report. diff --git a/lib/mobilizon/reports/note.ex b/lib/mobilizon/reports/note.ex index a7d8ad30e..56d1993c7 100644 --- a/lib/mobilizon/reports/note.ex +++ b/lib/mobilizon/reports/note.ex @@ -7,6 +7,7 @@ defmodule Mobilizon.Reports.Note do alias Mobilizon.Actors.Actor alias Mobilizon.Reports.Report + @timestamps_opts [type: :utc_datetime] @attrs [:content, :moderator_id, :report_id] @derive {Jason.Encoder, only: [:content]} diff --git a/lib/mobilizon/reports/report.ex b/lib/mobilizon/reports/report.ex index 11d55e824..2d5b5b0cc 100644 --- a/lib/mobilizon/reports/report.ex +++ b/lib/mobilizon/reports/report.ex @@ -17,6 +17,8 @@ defmodule Mobilizon.Reports.Report do alias Mobilizon.Actors.Actor alias Mobilizon.Reports.Note + @timestamps_opts [type: :utc_datetime] + @derive {Jason.Encoder, only: [:status, :uri]} schema "reports" do field(:content, :string) @@ -48,7 +50,7 @@ defmodule Mobilizon.Reports.Report do def changeset(report, attrs) do report |> 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 def creation_changeset(report, attrs) do diff --git a/lib/mobilizon_web/api/events.ex b/lib/mobilizon_web/api/events.ex index f841d2597..6a9493a25 100644 --- a/lib/mobilizon_web/api/events.ex +++ b/lib/mobilizon_web/api/events.ex @@ -135,4 +135,13 @@ defmodule MobilizonWeb.API.Events do } 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 diff --git a/lib/mobilizon_web/api/reports.ex b/lib/mobilizon_web/api/reports.ex index 3010c6ecb..0560c80f4 100644 --- a/lib/mobilizon_web/api/reports.ex +++ b/lib/mobilizon_web/api/reports.ex @@ -21,21 +21,17 @@ defmodule MobilizonWeb.API.Reports do def report( %{ reporter_actor_id: reporter_actor_id, - reported_actor_id: reported_actor_id, - event_id: event_id, - comments_ids: comments_ids, - report_content: report_content + reported_actor_id: reported_actor_id } = args ) do with {:reporter, %Actor{url: reporter_url} = _reporter_actor} <- {:reporter, Actors.get_actor!(reporter_actor_id)}, {:reported, %Actor{url: reported_actor_url} = reported_actor} <- {:reported, Actors.get_actor!(reported_actor_id)}, - {:ok, content} <- make_report_content_html(report_content), - {:ok, event} <- - if(event_id, do: Events.get_event(event_id), else: {:ok, nil}), + {:ok, content} <- args |> Map.get(:content, nil) |> make_report_content_text(), + {:ok, event} <- args |> Map.get(:event_id, nil) |> get_event(), {: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, ActivityPub.flag(%{ @@ -49,6 +45,7 @@ defmodule MobilizonWeb.API.Reports do })} do {:ok, activity, report} else + {:make_activity, err} -> {:error, err} {:error, err} -> {:error, err} {:actor_id, %{}} -> {:error, "Valid `actor_id` required"} {:reporter, nil} -> {:error, "Reporter Actor not found"} @@ -56,6 +53,9 @@ defmodule MobilizonWeb.API.Reports do end end + defp get_event(nil), do: {:ok, nil} + defp get_event(event_id), do: Events.get_event(event_id) + @doc """ Update the state of a report """ diff --git a/lib/mobilizon_web/api/utils.ex b/lib/mobilizon_web/api/utils.ex index 9de90689b..d54a1de22 100644 --- a/lib/mobilizon_web/api/utils.ex +++ b/lib/mobilizon_web/api/utils.ex @@ -122,9 +122,9 @@ defmodule MobilizonWeb.API.Utils do # |> Formatter.html_escape("text/html") # 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) if String.length(comment) <= max_size do diff --git a/lib/mobilizon_web/controllers/node_info_controller.ex b/lib/mobilizon_web/controllers/node_info_controller.ex index 31bf6bc18..aad33df9f 100644 --- a/lib/mobilizon_web/controllers/node_info_controller.ex +++ b/lib/mobilizon_web/controllers/node_info_controller.ex @@ -6,8 +6,8 @@ defmodule MobilizonWeb.NodeInfoController do use MobilizonWeb, :controller - alias Mobilizon.{Events, Users} alias Mobilizon.CommonConfig + alias Mobilizon.Service.Statistics @instance Application.get_env(:mobilizon, :instance) @node_info_supported_versions ["2.0", "2.1"] @@ -34,7 +34,7 @@ defmodule MobilizonWeb.NodeInfoController do response = %{ version: version, software: %{ - name: "mobilizon", + name: "Mobilizon", version: Keyword.get(@instance, :version) }, protocols: ["activitypub"], @@ -45,10 +45,10 @@ defmodule MobilizonWeb.NodeInfoController do openRegistrations: CommonConfig.registrations_open?(), usage: %{ users: %{ - total: Users.count_users() + total: Statistics.get_cached_value(:local_users) }, - localPosts: Events.count_local_events(), - localComments: Events.count_local_comments() + localPosts: Statistics.get_cached_value(:local_events), + localComments: Statistics.get_cached_value(:local_comments) }, metadata: %{ nodeName: CommonConfig.instance_name(), diff --git a/lib/mobilizon_web/resolvers/admin.ex b/lib/mobilizon_web/resolvers/admin.ex index 4f9972428..944b50ddb 100644 --- a/lib/mobilizon_web/resolvers/admin.ex +++ b/lib/mobilizon_web/resolvers/admin.ex @@ -2,10 +2,13 @@ defmodule MobilizonWeb.Resolvers.Admin do @moduledoc """ Handles the report-related GraphQL calls """ + alias Mobilizon.Events alias Mobilizon.Users.User import Mobilizon.Users.Guards alias Mobilizon.Admin.ActionLog alias Mobilizon.Reports.{Report, Note} + alias Mobilizon.Events.Event + alias Mobilizon.Service.Statistics def list_action_logs(_parent, %{page: page, limit: limit}, %{ context: %{current_user: %User{role: role}} @@ -17,14 +20,19 @@ defmodule MobilizonWeb.Resolvers.Admin do target_type: target_type, action: action, actor: actor, - id: id + id: id, + inserted_at: inserted_at } = action_log -> - transform_action_log(target_type, action, action_log) - |> Map.merge(%{ - actor: actor, - id: id - }) + with data when is_map(data) <- + transform_action_log(String.to_existing_atom(target_type), action, action_log) do + Map.merge(data, %{ + actor: actor, + id: id, + inserted_at: inserted_at + }) + end end) + |> Enum.filter(& &1) {:ok, action_logs} end @@ -35,38 +43,87 @@ defmodule MobilizonWeb.Resolvers.Admin do end defp transform_action_log( - "Elixir.Mobilizon.Reports.Report", - "update", + Report, + :update, %ActionLog{} = action_log ) 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 } end end - defp transform_action_log("Elixir.Mobilizon.Reports.Note", "create", %ActionLog{ + defp transform_action_log(Note, :create, %ActionLog{ changes: changes }) do %{ - action: "note_creation", + action: :note_creation, object: convert_changes_to_struct(Note, changes) } end - defp transform_action_log("Elixir.Mobilizon.Reports.Note", "delete", %ActionLog{ + defp transform_action_log(Note, :delete, %ActionLog{ changes: changes }) do %{ - action: "note_deletion", + action: :note_deletion, object: convert_changes_to_struct(Note, changes) } 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 + 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 - 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 diff --git a/lib/mobilizon_web/resolvers/event.ex b/lib/mobilizon_web/resolvers/event.ex index 869a91e93..d5c4734fd 100644 --- a/lib/mobilizon_web/resolvers/event.ex +++ b/lib/mobilizon_web/resolvers/event.ex @@ -9,7 +9,10 @@ defmodule MobilizonWeb.Resolvers.Event do alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Media.Picture alias Mobilizon.Users.User + alias Mobilizon.Actors + alias Mobilizon.Actors.Actor alias MobilizonWeb.Resolvers.Person + import Mobilizon.Service.Admin.ActionLogService # We limit the max number of events that can be retrieved @event_max_limit 100 @@ -328,28 +331,43 @@ defmodule MobilizonWeb.Resolvers.Event do %{event_id: event_id, actor_id: actor_id}, %{ context: %{ - current_user: user + current_user: %User{role: role} = user } } ) do - with {:ok, %Event{} = event} <- Mobilizon.Events.get_event(event_id), - {:is_owned, true, _} <- User.owns_actor(user, actor_id), - {:event_can_be_managed, true} <- Event.can_event_be_managed_by(event, actor_id), - event <- Mobilizon.Events.delete_event!(event) do - {:ok, %{id: event.id}} + with {:ok, %Event{local: is_local} = event} <- Mobilizon.Events.get_event_full(event_id), + {actor_id, ""} <- Integer.parse(actor_id), + {:is_owned, true, _} <- User.owns_actor(user, actor_id) do + cond do + 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 {:error, :event_not_found} -> {:error, "Event not found"} {:is_owned, false} -> {:error, "Actor id is not owned by authenticated user"} - - {:event_can_be_managed, false} -> - {:error, "You cannot delete this event"} end end def delete_event(_parent, _args, _resolution) do {:error, "You need to be logged-in to delete an event"} 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 diff --git a/lib/mobilizon_web/resolvers/group.ex b/lib/mobilizon_web/resolvers/group.ex index 84239788a..2fcb0d44c 100644 --- a/lib/mobilizon_web/resolvers/group.ex +++ b/lib/mobilizon_web/resolvers/group.ex @@ -89,7 +89,9 @@ defmodule MobilizonWeb.Resolvers.Group 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), {:ok, %Member{} = member} <- Member.get_member(actor_id, group.id), {:is_admin, true} <- Member.is_administrator(member), @@ -126,7 +128,9 @@ defmodule MobilizonWeb.Resolvers.Group 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), {: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)}, @@ -180,7 +184,9 @@ defmodule MobilizonWeb.Resolvers.Group 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), {:only_administrator, false} <- {:only_administrator, check_that_member_is_not_last_administrator(group_id, actor_id)}, diff --git a/lib/mobilizon_web/resolvers/report.ex b/lib/mobilizon_web/resolvers/report.ex index dd3655050..51f815513 100644 --- a/lib/mobilizon_web/resolvers/report.ex +++ b/lib/mobilizon_web/resolvers/report.ex @@ -10,11 +10,11 @@ defmodule MobilizonWeb.Resolvers.Report do alias MobilizonWeb.API.Reports, as: ReportsAPI 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}} }) when is_moderator(role) do - {:ok, Mobilizon.Reports.list_reports(page, limit)} + {:ok, Mobilizon.Reports.list_reports(page, limit, :updated_at, :desc, status)} end def list_reports(_parent, _args, _resolution) do @@ -25,7 +25,13 @@ defmodule MobilizonWeb.Resolvers.Report do context: %{current_user: %User{role: role}} }) 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 def get_report(_parent, _args, _resolution) do diff --git a/lib/mobilizon_web/router.ex b/lib/mobilizon_web/router.ex index 2ecd44af4..3375341d3 100644 --- a/lib/mobilizon_web/router.ex +++ b/lib/mobilizon_web/router.ex @@ -78,6 +78,9 @@ defmodule MobilizonWeb.Router do get("/events/create", PageController, :index) get("/events/list", 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 scope "/", MobilizonWeb do diff --git a/lib/mobilizon_web/schema.ex b/lib/mobilizon_web/schema.ex index c3819ab75..5445b49f8 100644 --- a/lib/mobilizon_web/schema.ex +++ b/lib/mobilizon_web/schema.ex @@ -4,7 +4,7 @@ defmodule MobilizonWeb.Schema do """ 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.Events.{Event, Comment, Participant} @@ -26,7 +26,7 @@ defmodule MobilizonWeb.Schema do @desc "A struct containing the id of the deleted object" object :deleted_object do - field(:id, :integer) + field(:id, :id) end @desc "A JWT and the associated user ID" @@ -44,7 +44,7 @@ defmodule MobilizonWeb.Schema do Represents a notification for an user """ 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(:actor, :actor, description: "The notification target profile") @@ -94,6 +94,7 @@ defmodule MobilizonWeb.Schema do |> Dataloader.add_source(Events, Events.data()) |> Dataloader.add_source(Addresses, Addresses.data()) |> Dataloader.add_source(Media, Media.data()) + |> Dataloader.add_source(Reports, Reports.data()) Map.put(ctx, :loader, loader) end diff --git a/lib/mobilizon_web/schema/actor.ex b/lib/mobilizon_web/schema/actor.ex index 7509c3a3b..89ea0cee5 100644 --- a/lib/mobilizon_web/schema/actor.ex +++ b/lib/mobilizon_web/schema/actor.ex @@ -13,7 +13,7 @@ defmodule MobilizonWeb.Schema.ActorInterface do @desc "An ActivityPub actor" 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(:type, :actor_type, description: "The type of Actor (Person, Group,…)") field(:name, :string, description: "The actor's displayed name") diff --git a/lib/mobilizon_web/schema/actors/group.ex b/lib/mobilizon_web/schema/actors/group.ex index 30226039d..dcf5929d4 100644 --- a/lib/mobilizon_web/schema/actors/group.ex +++ b/lib/mobilizon_web/schema/actors/group.ex @@ -14,7 +14,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do object :group do 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(:type, :actor_type, description: "The type of Actor (Person, Group,…)") field(:name, :string, description: "The actor's displayed name") @@ -96,9 +96,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do field :create_group, :group do arg(:preferred_username, non_null(:string), description: "The name for the group") - arg(:creator_actor_id, non_null(:integer), - description: "The identity that creates the group" - ) + arg(:creator_actor_id, non_null(:id), description: "The identity that creates the group") arg(:name, :string, description: "The displayed name for the group") 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" field :delete_group, :deleted_object do - arg(:group_id, non_null(:integer)) - arg(:actor_id, non_null(:integer)) + arg(:group_id, non_null(:id)) + arg(:actor_id, non_null(:id)) resolve(&Group.delete_group/3) end diff --git a/lib/mobilizon_web/schema/actors/member.ex b/lib/mobilizon_web/schema/actors/member.ex index 95bb75b6b..920138897 100644 --- a/lib/mobilizon_web/schema/actors/member.ex +++ b/lib/mobilizon_web/schema/actors/member.ex @@ -24,16 +24,16 @@ defmodule MobilizonWeb.Schema.Actors.MemberType do object :member_mutations do @desc "Join a group" field :join_group, :member do - arg(:group_id, non_null(:integer)) - arg(:actor_id, non_null(:integer)) + arg(:group_id, non_null(:id)) + arg(:actor_id, non_null(:id)) resolve(&Resolvers.Group.join_group/3) end @desc "Leave an event" field :leave_group, :deleted_member do - arg(:group_id, non_null(:integer)) - arg(:actor_id, non_null(:integer)) + arg(:group_id, non_null(:id)) + arg(:actor_id, non_null(:id)) resolve(&Resolvers.Group.leave_group/3) end diff --git a/lib/mobilizon_web/schema/actors/person.ex b/lib/mobilizon_web/schema/actors/person.ex index a8e278456..ef7f8b4b7 100644 --- a/lib/mobilizon_web/schema/actors/person.ex +++ b/lib/mobilizon_web/schema/actors/person.ex @@ -15,7 +15,7 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do """ object :person do 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(:member_of, list_of(:member), description: "The list of groups this person is member of") diff --git a/lib/mobilizon_web/schema/address.ex b/lib/mobilizon_web/schema/address.ex index 71f69a951..9a9006f83 100644 --- a/lib/mobilizon_web/schema/address.ex +++ b/lib/mobilizon_web/schema/address.ex @@ -15,7 +15,7 @@ defmodule MobilizonWeb.Schema.AddressType do field(:country, :string) field(:description, :string) field(:url, :string) - field(:id, :integer) + field(:id, :id) field(:origin_id, :string) end @@ -40,7 +40,7 @@ defmodule MobilizonWeb.Schema.AddressType do field(:country, :string) field(:description, :string) field(:url, :string) - field(:id, :integer) + field(:id, :id) field(:origin_id, :string) end diff --git a/lib/mobilizon_web/schema/admin.ex b/lib/mobilizon_web/schema/admin.ex index fa73ec0a9..715d7fdda 100644 --- a/lib/mobilizon_web/schema/admin.ex +++ b/lib/mobilizon_web/schema/admin.ex @@ -5,13 +5,25 @@ defmodule MobilizonWeb.Schema.AdminType do use Absinthe.Schema.Notation alias MobilizonWeb.Resolvers.Admin alias Mobilizon.Reports.{Report, Note} + alias Mobilizon.Events.Event @desc "An action log" object :action_log do field(:id, :id, description: "Internal ID for this comment") field(:actor, :actor, description: "The actor that acted") 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 @desc "The objects that can be in an action log" @@ -25,11 +37,22 @@ defmodule MobilizonWeb.Schema.AdminType do %Note{}, _ -> :report_note + %Event{}, _ -> + :event + _, _ -> nil 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 @desc "Get the list of action logs" field :action_logs, type: list_of(:action_log) do @@ -37,5 +60,9 @@ defmodule MobilizonWeb.Schema.AdminType do arg(:limit, :integer, default_value: 10) resolve(&Admin.list_action_logs/3) end + + field :dashboard, type: :dashboard do + resolve(&Admin.get_dashboard/3) + end end end diff --git a/lib/mobilizon_web/schema/event.ex b/lib/mobilizon_web/schema/event.ex index e1c15161b..c2f2fbfe3 100644 --- a/lib/mobilizon_web/schema/event.ex +++ b/lib/mobilizon_web/schema/event.ex @@ -12,7 +12,8 @@ defmodule MobilizonWeb.Schema.EventType do @desc "An event" 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(:url, :string, description: "The ActivityPub Event URL") field(:local, :boolean, description: "Whether the event is local or not") @@ -261,8 +262,8 @@ defmodule MobilizonWeb.Schema.EventType do @desc "Delete an event" field :delete_event, :deleted_object do - arg(:event_id, non_null(:integer)) - arg(:actor_id, non_null(:integer)) + arg(:event_id, non_null(:id)) + arg(:actor_id, non_null(:id)) resolve(&Event.delete_event/3) end diff --git a/lib/mobilizon_web/schema/events/feed_token.ex b/lib/mobilizon_web/schema/events/feed_token.ex index 7be904907..15cf80e63 100644 --- a/lib/mobilizon_web/schema/events/feed_token.ex +++ b/lib/mobilizon_web/schema/events/feed_token.ex @@ -36,7 +36,7 @@ defmodule MobilizonWeb.Schema.Events.FeedTokenType do object :feed_token_mutations do @desc "Create a Feed Token" field :create_feed_token, :feed_token do - arg(:actor_id, :integer) + arg(:actor_id, :id) resolve(&Resolvers.FeedToken.create_feed_token/3) end diff --git a/lib/mobilizon_web/schema/events/participant.ex b/lib/mobilizon_web/schema/events/participant.ex index 75f9c8954..6f0e2e2e8 100644 --- a/lib/mobilizon_web/schema/events/participant.ex +++ b/lib/mobilizon_web/schema/events/participant.ex @@ -46,16 +46,16 @@ defmodule MobilizonWeb.Schema.Events.ParticipantType do object :participant_mutations do @desc "Join an event" field :join_event, :participant do - arg(:event_id, non_null(:integer)) - arg(:actor_id, non_null(:integer)) + arg(:event_id, non_null(:id)) + arg(:actor_id, non_null(:id)) resolve(&Resolvers.Event.actor_join_event/3) end @desc "Leave an event" field :leave_event, :deleted_participant do - arg(:event_id, non_null(:integer)) - arg(:actor_id, non_null(:integer)) + arg(:event_id, non_null(:id)) + arg(:actor_id, non_null(:id)) resolve(&Resolvers.Event.actor_leave_event/3) end diff --git a/lib/mobilizon_web/schema/report.ex b/lib/mobilizon_web/schema/report.ex index ea3a5cdaf..7e9acec4e 100644 --- a/lib/mobilizon_web/schema/report.ex +++ b/lib/mobilizon_web/schema/report.ex @@ -3,6 +3,8 @@ defmodule MobilizonWeb.Schema.ReportType do Schema representation for User """ use Absinthe.Schema.Notation + import Absinthe.Resolution.Helpers, only: [dataloader: 1] + alias Mobilizon.Reports alias MobilizonWeb.Resolvers.Report @@ -17,6 +19,14 @@ defmodule MobilizonWeb.Schema.ReportType do field(:reporter, :actor, description: "The actor that created the report") field(:event, :event, description: "The event that is being 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 @desc "A report note object" @@ -24,8 +34,14 @@ defmodule MobilizonWeb.Schema.ReportType do interfaces([:action_log_object]) field(:id, :id, description: "The internal ID of the report 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(:inserted_at, :datetime, description: "When the report note was created") end @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 arg(:page, :integer, default_value: 1) arg(:limit, :integer, default_value: 10) + arg(:status, :report_status, default_value: :open) resolve(&Report.list_reports/3) end @@ -53,7 +70,7 @@ defmodule MobilizonWeb.Schema.ReportType do object :report_mutations do @desc "Create a report" field :create_report, type: :report do - arg(:report_content, :string) + arg(:content, :string) arg(:reporter_actor_id, non_null(:id)) arg(:reported_actor_id, non_null(:id)) arg(:event_id, :id, default_value: nil) diff --git a/lib/mobilizon_web/schema/user.ex b/lib/mobilizon_web/schema/user.ex index 1b3a525e6..f1725b382 100644 --- a/lib/mobilizon_web/schema/user.ex +++ b/lib/mobilizon_web/schema/user.ex @@ -43,6 +43,14 @@ defmodule MobilizonWeb.Schema.UserType do resolve: dataloader(Events), 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 @desc "Token" diff --git a/lib/mobilizon_web/templates/email/report.html.eex b/lib/mobilizon_web/templates/email/report.html.eex index 3740eea67..870122bbc 100644 --- a/lib/mobilizon_web/templates/email/report.html.eex +++ b/lib/mobilizon_web/templates/email/report.html.eex @@ -1,15 +1,15 @@ -

<%= 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 %> -

<%= gettext "Event: %{event}", event: @report.event %>

+

<%= gettext "Event: %{event}", event: @report.event.title %>

<% end %> <%= for comment <- @report.comments do %>

<%= gettext "Comment: %{comment}", comment: comment %>

<% end %> -<% if @content do %> +<% if @report.content do %>

<%= gettext "Reason: %{content}", event: @report.content %>

<% end %> -

<%= link "View the report", to: MobilizonWeb.Endpoint.url() <> "/reports/#{@report.id}", target: "_blank" %>

+

<%= link "View the report", to: moderation_report_url(MobilizonWeb.Endpoint, :index, @report.id), target: "_blank" %>

\ No newline at end of file diff --git a/lib/mobilizon_web/templates/email/report.text.eex b/lib/mobilizon_web/templates/email/report.text.eex index af4233ece..ddd066e34 100644 --- a/lib/mobilizon_web/templates/email/report.text.eex +++ b/lib/mobilizon_web/templates/email/report.text.eex @@ -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 %> - <%= gettext "Event: %{event}", event: @report.event %> + <%= gettext "Event: %{event}", event: @report.event.title %> <% end %> <%= for comment <- @report.comments do %> -<%= gettext "Comment: %{comment}", comment: comment %> +<%= gettext "Comment: %{comment}", comment: comment.text %> <% end %> -<% if @content do %> +<% if @report.content do %> <%= gettext "Reason: %{content}", event: @report.content %> <% 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) %> diff --git a/lib/service/activity_pub/activity_pub.ex b/lib/service/activity_pub/activity_pub.ex index 6cad45aab..e111dac0f 100644 --- a/lib/service/activity_pub/activity_pub.ex +++ b/lib/service/activity_pub/activity_pub.ex @@ -335,9 +335,8 @@ defmodule Mobilizon.Service.ActivityPub do with {:ok, _} <- Events.delete_event(event), {:ok, activity} <- create_activity(data, local), - {:ok, object} <- insert_full_object(data), :ok <- maybe_federate(activity) do - {:ok, activity, object} + {:ok, activity, event} end end @@ -521,7 +520,8 @@ defmodule Mobilizon.Service.ActivityPub do 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) Mobilizon.Service.ActivityPub.Relay.publish(activity) end @@ -552,6 +552,9 @@ defmodule Mobilizon.Service.ActivityPub do end) end + defp is_delete_activity?(%Activity{data: %{"type" => "Delete"}}), do: true + defp is_delete_activity?(_), do: false + @doc """ Publish an activity to a specific inbox """ diff --git a/lib/service/activity_pub/utils.ex b/lib/service/activity_pub/utils.ex index d68c62287..b0c40d748 100644 --- a/lib/service/activity_pub/utils.ex +++ b/lib/service/activity_pub/utils.ex @@ -164,14 +164,14 @@ defmodule Mobilizon.Service.ActivityPub.Utils do {:ok, %Report{} = report} <- Reports.create_report(data) do Enum.each(Users.list_moderators(), fn moderator -> moderator - |> Mobilizon.Email.Admin.report(moderator, report) + |> Mobilizon.Email.Admin.report(report) |> Mobilizon.Mailer.deliver_later() end) {:ok, report} else err -> - Logger.error("Error while inserting a remote comment inside database") + Logger.error("Error while inserting report inside database") Logger.debug(inspect(err)) {:error, err} end diff --git a/lib/service/admin/action_log_service.ex b/lib/service/admin/action_log_service.ex index b1a636853..6f82ead2f 100644 --- a/lib/service/admin/action_log_service.ex +++ b/lib/service/admin/action_log_service.ex @@ -22,9 +22,19 @@ defmodule Mobilizon.Service.Admin.ActionLogService do "target_type" => to_string(target.__struct__), "target_id" => target.id, "action" => action, - "changes" => Map.from_struct(target) |> Map.take([:status, :uri, :content]) + "changes" => stringify_struct(target) }) do {:ok, create_action_log} 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 diff --git a/lib/service/statistics.ex b/lib/service/statistics.ex new file mode 100644 index 000000000..9d1f07ee8 --- /dev/null +++ b/lib/service/statistics.ex @@ -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 diff --git a/schema.graphql b/schema.graphql index b0951bde2..df2395faf 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,5 +1,5 @@ # source: http://localhost:4000/api -# timestamp: Mon Sep 09 2019 11:37:31 GMT+0200 (heure d’été d’Europe centrale) +# timestamp: Mon Sep 09 2019 20:33:17 GMT+0200 (GMT+02:00) schema { query: RootQueryType @@ -9,7 +9,7 @@ schema { """An action log""" type ActionLog { """The action that was done""" - action: String + action: ActionLogAction """The actor that acted""" actor: Actor @@ -17,10 +17,23 @@ type ActionLog { """Internal ID for this comment""" id: ID + """The time when the action was performed""" + insertedAt: DateTime + """The object that was acted upon""" 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""" interface ActionLogObject { """Internal ID for this object""" @@ -51,7 +64,7 @@ interface Actor { followingCount: Int """Internal ID for this actor""" - id: Int + id: ID """The actors RSA Keys""" keys: String @@ -111,7 +124,7 @@ type Address { """The geocoordinates for the point where this address is""" geom: Point - id: Int + id: ID """The address's locality""" locality: String @@ -133,7 +146,7 @@ input AddressInput { """The geocoordinates for the point where this address is""" geom: Point - id: Int + id: ID """The address's locality""" locality: String @@ -185,6 +198,23 @@ type Config { 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 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""" type DeletedObject { - id: Int + id: ID } """Represents a deleted participant""" @@ -217,7 +247,7 @@ type DeletedParticipant { } """An event""" -type Event { +type Event implements ActionLogObject { """Who the event is attributed to (often a group)""" attributedTo: Actor @@ -237,7 +267,7 @@ type Event { endsOn: DateTime """Internal ID for this event""" - id: Int + id: ID """Whether the event is local or not""" local: Boolean @@ -501,7 +531,7 @@ type Group implements Actor { followingCount: Int """Internal ID for this group""" - id: Int + id: ID """The actors RSA Keys""" keys: String @@ -651,7 +681,7 @@ type Person implements Actor { goingToEvents: [Event] """Internal ID for this person""" - id: Int + id: ID """The actors RSA Keys""" keys: String @@ -763,6 +793,12 @@ type Report implements ActionLogObject { """The internal ID of the report""" id: ID + """When the report was created""" + insertedAt: DateTime + + """The notes made on the event""" + notes: [ReportNote] + """The actor that is being reported""" reported: Actor @@ -772,6 +808,9 @@ type Report implements ActionLogObject { """Whether the report is still active""" status: ReportStatus + """When the report was updated""" + updatedAt: DateTime + """The URI of the report""" uri: String } @@ -784,6 +823,9 @@ type ReportNote implements ActionLogObject { """The internal ID of the report note""" id: ID + """When the report note was created""" + insertedAt: DateTime + """The moderator who added the note""" moderator: Actor @@ -827,7 +869,7 @@ type RootMutationType { """ picture: PictureInput publishAt: DateTime - status: Int + status: EventStatus """The list of tags associated to the event""" tags: [String] = [""] @@ -836,7 +878,7 @@ type RootMutationType { ): Event """Create a Feed Token""" - createFeedToken(actorId: Int): FeedToken + createFeedToken(actorId: ID): FeedToken """Create a group""" createGroup( @@ -851,7 +893,7 @@ type RootMutationType { banner: PictureInput """The identity that creates the group""" - creatorActorId: Int! + creatorActorId: ID! """The displayed name for the group""" name: String @@ -884,7 +926,7 @@ type RootMutationType { ): Person """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""" createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote @@ -893,29 +935,29 @@ type RootMutationType { createUser(email: String!, password: String!): User """Delete an event""" - deleteEvent(actorId: Int!, eventId: Int!): DeletedObject + deleteEvent(actorId: ID!, eventId: ID!): DeletedObject """Delete a feed token""" deleteFeedToken(token: String!): DeletedFeedToken """Delete a group""" - deleteGroup(actorId: Int!, groupId: Int!): DeletedObject + deleteGroup(actorId: ID!, groupId: ID!): DeletedObject """Delete an identity""" deletePerson(preferredUsername: String!): Person deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject """Join an event""" - joinEvent(actorId: Int!, eventId: Int!): Participant + joinEvent(actorId: ID!, eventId: ID!): Participant """Join a group""" - joinGroup(actorId: Int!, groupId: Int!): Member + joinGroup(actorId: ID!, groupId: ID!): Member """Leave an event""" - leaveEvent(actorId: Int!, eventId: Int!): DeletedParticipant + leaveEvent(actorId: ID!, eventId: ID!): DeletedParticipant """Leave an event""" - leaveGroup(actorId: Int!, groupId: Int!): DeletedMember + leaveGroup(actorId: ID!, groupId: ID!): DeletedMember """Login an user""" login(email: String!, password: String!): Login @@ -1019,6 +1061,7 @@ type RootQueryType { """Get the instance config""" config: Config + dashboard: Dashboard """Get an event by uuid""" event(uuid: UUID!): Event @@ -1054,7 +1097,7 @@ type RootQueryType { report(id: ID!): Report """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""" reverseGeocode(latitude: Float!, longitude: Float!): [Address] @@ -1144,6 +1187,15 @@ type User { """The token sent when requesting password token""" resetPasswordToken: String + + """The role for the user""" + role: UserRole +} + +enum UserRole { + ADMINISTRATOR + MODERATOR + USER } """Users list""" diff --git a/test/mobilizon/service/activity_pub/activity_pub_test.exs b/test/mobilizon/service/activity_pub/activity_pub_test.exs index c744d3d68..ff3c0d018 100644 --- a/test/mobilizon/service/activity_pub/activity_pub_test.exs +++ b/test/mobilizon/service/activity_pub/activity_pub_test.exs @@ -15,6 +15,7 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do alias Mobilizon.Service.HTTPSignatures.Signature alias Mobilizon.Service.ActivityPub use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + import Mock setup_all do HTTPoison.start() @@ -111,7 +112,6 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do end 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 event = insert(:event) 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 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 comment = insert(:comment) comment = Events.get_comment_full_from_url!(comment.url) diff --git a/test/mobilizon/service/admin/action_log_service_test.exs b/test/mobilizon/service/admin/action_log_service_test.exs index ccdfba86b..b96a3eefc 100644 --- a/test/mobilizon/service/admin/action_log_service_test.exs +++ b/test/mobilizon/service/admin/action_log_service_test.exs @@ -22,7 +22,7 @@ defmodule Mobilizon.Service.Admin.ActionLogServiceTest do %ActionLog{ target_type: "Elixir.Mobilizon.Reports.Report", target_id: report_id, - action: "update", + action: :update, actor: moderator }} = log_action(moderator, "update", report) end @@ -35,7 +35,7 @@ defmodule Mobilizon.Service.Admin.ActionLogServiceTest do %ActionLog{ target_type: "Elixir.Mobilizon.Reports.Note", target_id: note_id, - action: "create", + action: :create, actor: moderator }} = log_action(moderator, "create", report) end diff --git a/test/mobilizon_web/api/report_test.exs b/test/mobilizon_web/api/report_test.exs index fa547024b..e757d00cd 100644 --- a/test/mobilizon_web/api/report_test.exs +++ b/test/mobilizon_web/api/report_test.exs @@ -25,7 +25,7 @@ defmodule MobilizonWeb.API.ReportTest do Reports.report(%{ reporter_actor_id: reporter_id, reported_actor_id: reported_id, - report_content: comment, + content: comment, event_id: event_id, comments_ids: [] }) @@ -58,7 +58,7 @@ defmodule MobilizonWeb.API.ReportTest do Reports.report(%{ reporter_actor_id: reporter_id, reported_actor_id: reported_id, - report_content: comment, + content: comment, event_id: nil, comments_ids: [comment_1_id, comment_2_id] }) @@ -92,7 +92,7 @@ defmodule MobilizonWeb.API.ReportTest do Reports.report(%{ reporter_actor_id: reporter_id, reported_actor_id: reported_id, - report_content: comment, + content: comment, event_id: nil, comments_ids: [comment_1_id, comment_2_id], forward: true @@ -121,7 +121,7 @@ defmodule MobilizonWeb.API.ReportTest do Reports.report(%{ reporter_actor_id: reporter_id, reported_actor_id: reported_id, - report_content: "This is not a nice thing", + content: "This is not a nice thing", event_id: nil, comments_ids: [comment_1_id], forward: true @@ -147,7 +147,7 @@ defmodule MobilizonWeb.API.ReportTest do Reports.report(%{ reporter_actor_id: reporter_id, reported_actor_id: reported_id, - report_content: "This is not a nice thing", + content: "This is not a nice thing", event_id: nil, comments_ids: [comment_1_id], forward: true diff --git a/test/mobilizon_web/controllers/nodeinfo_controller_test.exs b/test/mobilizon_web/controllers/nodeinfo_controller_test.exs index 44592c05c..5bd4f8303 100644 --- a/test/mobilizon_web/controllers/nodeinfo_controller_test.exs +++ b/test/mobilizon_web/controllers/nodeinfo_controller_test.exs @@ -31,8 +31,9 @@ defmodule MobilizonWeb.NodeInfoControllerTest do end 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")) - resp = json_response(conn, 200) assert resp == %{ @@ -44,7 +45,7 @@ defmodule MobilizonWeb.NodeInfoControllerTest do "protocols" => ["activitypub"], "services" => %{"inbound" => [], "outbound" => ["atom1.0"]}, "software" => %{ - "name" => "mobilizon", + "name" => "Mobilizon", "version" => Keyword.get(@instance, :version), "repository" => Keyword.get(@instance, :repository) }, diff --git a/test/mobilizon_web/resolvers/admin_resolver_test.exs b/test/mobilizon_web/resolvers/admin_resolver_test.exs index 150cb7bc4..a1452d488 100644 --- a/test/mobilizon_web/resolvers/admin_resolver_test.exs +++ b/test/mobilizon_web/resolvers/admin_resolver_test.exs @@ -3,6 +3,7 @@ defmodule MobilizonWeb.Resolvers.AdminResolverTest do use MobilizonWeb.ConnCase import Mobilizon.Factory + alias Mobilizon.Events.Event alias Mobilizon.Actors.Actor alias Mobilizon.Users.User alias Mobilizon.Reports.{Report, Note} @@ -62,21 +63,60 @@ defmodule MobilizonWeb.Resolvers.AdminResolverTest do 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}, "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 + + 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 diff --git a/test/mobilizon_web/resolvers/event_resolver_test.exs b/test/mobilizon_web/resolvers/event_resolver_test.exs index b6ce817d2..dd5752ca4 100644 --- a/test/mobilizon_web/resolvers/event_resolver_test.exs +++ b/test/mobilizon_web/resolvers/event_resolver_test.exs @@ -731,7 +731,7 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) 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 = conn @@ -815,6 +815,72 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do assert hd(json_response(res, 200)["errors"])["message"] =~ "cannot delete" 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", %{ conn: conn, actor: actor diff --git a/test/mobilizon_web/resolvers/feed_token_resolver_test.exs b/test/mobilizon_web/resolvers/feed_token_resolver_test.exs index 8c5d122c7..01b277e26 100644 --- a/test/mobilizon_web/resolvers/feed_token_resolver_test.exs +++ b/test/mobilizon_web/resolvers/feed_token_resolver_test.exs @@ -43,7 +43,8 @@ defmodule MobilizonWeb.Resolvers.FeedTokenResolverTest do assert json_response(res, 200)["data"]["createFeedToken"]["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 query = """ @@ -209,8 +210,12 @@ defmodule MobilizonWeb.Resolvers.FeedTokenResolverTest do |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) 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 = """ { diff --git a/test/mobilizon_web/resolvers/group_resolver_test.exs b/test/mobilizon_web/resolvers/group_resolver_test.exs index 7373b2f14..784aa0de0 100644 --- a/test/mobilizon_web/resolvers/group_resolver_test.exs +++ b/test/mobilizon_web/resolvers/group_resolver_test.exs @@ -163,7 +163,7 @@ defmodule MobilizonWeb.Resolvers.GroupResolverTest do |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) 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 = conn diff --git a/test/mobilizon_web/resolvers/member_resolver_test.exs b/test/mobilizon_web/resolvers/member_resolver_test.exs index 413d04e78..cda9c1818 100644 --- a/test/mobilizon_web/resolvers/member_resolver_test.exs +++ b/test/mobilizon_web/resolvers/member_resolver_test.exs @@ -40,8 +40,8 @@ defmodule MobilizonWeb.Resolvers.MemberResolverTest do assert json_response(res, 200)["errors"] == nil 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"]["actor"]["id"] == actor.id + assert json_response(res, 200)["data"]["joinGroup"]["parent"]["id"] == to_string(group.id) + assert json_response(res, 200)["data"]["joinGroup"]["actor"]["id"] == to_string(actor.id) mutation = """ mutation { @@ -167,8 +167,8 @@ defmodule MobilizonWeb.Resolvers.MemberResolverTest do |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) 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"]["actor"]["id"] == actor.id + assert json_response(res, 200)["data"]["leaveGroup"]["parent"]["id"] == to_string(group.id) + assert json_response(res, 200)["data"]["leaveGroup"]["actor"]["id"] == to_string(actor.id) end test "leave_group/3 should check if the member is the only administrator", %{ diff --git a/test/mobilizon_web/resolvers/participant_resolver_test.exs b/test/mobilizon_web/resolvers/participant_resolver_test.exs index c2a3bbb48..dc2b287fb 100644 --- a/test/mobilizon_web/resolvers/participant_resolver_test.exs +++ b/test/mobilizon_web/resolvers/participant_resolver_test.exs @@ -50,8 +50,8 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do assert json_response(res, 200)["errors"] == nil 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"]["actor"]["id"] == actor.id + assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == to_string(event.id) + assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == to_string(actor.id) mutation = """ mutation { @@ -119,7 +119,7 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) 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 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)) 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"]["actor"]["id"] == participant.actor.id + assert json_response(res, 200)["data"]["leaveEvent"]["event"]["id"] == to_string(event.id) + + assert json_response(res, 200)["data"]["leaveEvent"]["actor"]["id"] == + to_string(participant.actor.id) query = """ { diff --git a/test/mobilizon_web/resolvers/report_resolver_test.exs b/test/mobilizon_web/resolvers/report_resolver_test.exs index 915ccbbe0..5a4c24a7a 100644 --- a/test/mobilizon_web/resolvers/report_resolver_test.exs +++ b/test/mobilizon_web/resolvers/report_resolver_test.exs @@ -21,7 +21,7 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do reporter_actor_id: #{reporter.id}, reported_actor_id: #{reported.id}, event_id: #{event.id}, - report_content: "This is an issue" + content: "This is an issue" ) { content, reporter { @@ -43,8 +43,10 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do 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"]["status"] == "OPEN" - assert json_response(res, 200)["data"]["createReport"]["event"]["id"] == event.id - assert json_response(res, 200)["data"]["createReport"]["reporter"]["id"] == reporter.id + assert json_response(res, 200)["data"]["createReport"]["event"]["id"] == to_string(event.id) + + assert json_response(res, 200)["data"]["createReport"]["reporter"]["id"] == + to_string(reporter.id) end 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( reported_actor_id: #{reported.id}, reporter_actor_id: 5, - report_content: "This is an issue" + content: "This is an issue" ) { 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"]["reporter"]["id"] == - report.reporter.id + to_string(report.reporter.id) end 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 %User{} = user_moderator = insert(:user, role: :moderator) - %Report{id: report_1_id} = insert(:report) - %Report{id: report_2_id} = insert(:report) - %Report{id: report_3_id} = insert(:report) + # Report don't hold millisecond information so we need to wait a bit + # between each insert to keep order + %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 = """ { @@ -182,7 +189,9 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do id, reported { preferredUsername - } + }, + content, + updatedAt } } """ @@ -196,7 +205,7 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do assert json_response(res, 200)["data"]["reports"] |> 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 = """ { @@ -360,7 +369,9 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) 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