Skip to content

Commit b834fd5

Browse files
committed
feat: notify author on deletion
adds notification to author when something is deleted by moderator. additionally it's possible to add reason for deletion for posts, collections, tags and answers that is logged in the audit log. closes #312
1 parent 7cf5add commit b834fd5

File tree

11 files changed

+475
-16
lines changed

11 files changed

+475
-16
lines changed

plugins/qeta-backend/src/service/NotificationManager.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,117 @@ export class NotificationManager {
131131
return notificationReceivers;
132132
}
133133

134+
async onPostDelete(
135+
username: string,
136+
post: Post,
137+
reason?: string,
138+
): Promise<string[]> {
139+
if (!this.notifications || !this.enabled || post.author === username) {
140+
return [];
141+
}
142+
143+
try {
144+
const user = await this.getUserDisplayName(username);
145+
146+
await this.notifications.send({
147+
recipients: {
148+
type: 'entity',
149+
entityRef: post.author,
150+
},
151+
payload: {
152+
title: `Deleted ${post.type}`,
153+
description: this.formatDescription(
154+
`${user} deleted your ${post.type} "${post.title}" with reason: ${
155+
reason || 'No reason provided'
156+
}`,
157+
),
158+
link:
159+
post.type === 'question'
160+
? `/qeta/questions/${post.id}`
161+
: `/qeta/articles/${post.id}`,
162+
topic: `${post.type} deleted`,
163+
scope: `${post.type}:delete:${post.id}`,
164+
},
165+
});
166+
} catch (e) {
167+
this.logger.error(`Failed to send notification for post delete: ${e}`);
168+
}
169+
return [post.author];
170+
}
171+
172+
async onCollectionDelete(
173+
username: string,
174+
collection: Collection,
175+
reason?: string,
176+
): Promise<string[]> {
177+
if (!this.notifications || !this.enabled || collection.owner === username) {
178+
return [];
179+
}
180+
181+
try {
182+
const user = await this.getUserDisplayName(username);
183+
184+
await this.notifications.send({
185+
recipients: {
186+
type: 'entity',
187+
entityRef: collection.owner,
188+
},
189+
payload: {
190+
title: `Deleted collection`,
191+
description: this.formatDescription(
192+
`${user} deleted your collection "${
193+
collection.title
194+
}" with reason: ${reason || 'No reason provided'}`,
195+
),
196+
link: `/qeta/collections/${collection.id}`,
197+
topic: `Collection deleted`,
198+
scope: `collection:delete:${collection.id}`,
199+
},
200+
});
201+
} catch (e) {
202+
this.logger.error(
203+
`Failed to send notification for collection delete: ${e}`,
204+
);
205+
}
206+
return [collection.owner];
207+
}
208+
209+
async onAnswerDelete(
210+
username: string,
211+
post: Post,
212+
answer: Answer,
213+
reason?: string,
214+
): Promise<string[]> {
215+
if (!this.notifications || !this.enabled || answer.author === username) {
216+
return [];
217+
}
218+
219+
try {
220+
const user = await this.getUserDisplayName(username);
221+
222+
await this.notifications.send({
223+
recipients: {
224+
type: 'entity',
225+
entityRef: answer.author,
226+
},
227+
payload: {
228+
title: `Deleted answer`,
229+
description: this.formatDescription(
230+
`${user} deleted your answer from question "${
231+
post.title
232+
}" with reason: ${reason || 'No reason provided'}`,
233+
),
234+
link: `/qeta/questions/${post.id}`,
235+
topic: `Answer deleted`,
236+
scope: `answer:delete:${answer.id}`,
237+
},
238+
});
239+
} catch (e) {
240+
this.logger.error(`Failed to send notification for answer delete: ${e}`);
241+
}
242+
return [answer.author];
243+
}
244+
134245
async onPostEdit(
135246
username: string,
136247
post: Post,

plugins/qeta-backend/src/service/routes/answers.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getCreated, mapAdditionalFields } from '../util';
44
import {
55
AnswersQuerySchema,
66
CommentSchema,
7+
DeleteMetadataSchema,
78
PostAnswerSchema,
89
RouteOptions,
910
} from '../types';
@@ -600,7 +601,12 @@ export const answersRoutes = (router: Router, options: RouteOptions) => {
600601
const username = await permissionMgr.getUsername(request);
601602
const postId = Number.parseInt(request.params.id, 10);
602603
const answerId = Number.parseInt(request.params.answerId, 10);
603-
if (Number.isNaN(postId) || Number.isNaN(answerId)) {
604+
const validateRequestBody = ajv.compile(DeleteMetadataSchema);
605+
if (
606+
Number.isNaN(postId) ||
607+
Number.isNaN(answerId) ||
608+
!validateRequestBody(request.body)
609+
) {
604610
response.status(400).send({ errors: 'Invalid id', type: 'body' });
605611
return;
606612
}
@@ -634,6 +640,14 @@ export const answersRoutes = (router: Router, options: RouteOptions) => {
634640
deleted = await database.deleteAnswer(answerId, true);
635641
} else {
636642
deleted = await database.deleteAnswer(answerId);
643+
if (deleted) {
644+
notificationMgr.onAnswerDelete(
645+
username,
646+
post,
647+
answer,
648+
request.body.reason,
649+
);
650+
}
637651
}
638652

639653
if (deleted) {
@@ -644,6 +658,7 @@ export const answersRoutes = (router: Router, options: RouteOptions) => {
644658
question: post,
645659
answer,
646660
author: username,
661+
reason: request.body.reason,
647662
},
648663
metadata: { action: 'delete_answer' },
649664
});
@@ -654,6 +669,7 @@ export const answersRoutes = (router: Router, options: RouteOptions) => {
654669
meta: {
655670
answer: entityToJsonObject(answer),
656671
post: entityToJsonObject(post),
672+
reason: request.body.reason,
657673
},
658674
});
659675
}

plugins/qeta-backend/src/service/routes/collections.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
CollectionRankPostSchema,
1717
CollectionSchema,
1818
CollectionsQuerySchema,
19+
DeleteMetadataSchema,
1920
RouteOptions,
2021
} from '../types';
2122
import { entityToJsonObject, validateDateRange, wrapAsync } from './util';
@@ -293,12 +294,12 @@ export const collectionsRoutes = (router: Router, options: RouteOptions) => {
293294
response.json(collection);
294295
});
295296

296-
// DELETE /questions/:id
297+
// DELETE /collections/:id
297298
router.delete('/collections/:id', async (request, response) => {
298299
// Validation
299-
300300
const collectionId = Number.parseInt(request.params.id, 10);
301-
if (Number.isNaN(collectionId)) {
301+
const validateRequestBody = ajv.compile(DeleteMetadataSchema);
302+
if (Number.isNaN(collectionId) || !validateRequestBody(request.body)) {
302303
response
303304
.status(400)
304305
.send({ errors: 'Invalid collection id', type: 'body' });
@@ -328,17 +329,26 @@ export const collectionsRoutes = (router: Router, options: RouteOptions) => {
328329
eventPayload: {
329330
collection,
330331
author: username,
332+
reason: request.body.reason,
331333
},
332334
metadata: { action: 'delete_collection' },
333335
});
334336
}
335337

336338
if (deleted) {
339+
notificationMgr.onCollectionDelete(
340+
username,
341+
collection,
342+
request.body.reason,
343+
);
337344
auditor?.createEvent({
338345
eventId: 'delete-collection',
339346
severityLevel: 'medium',
340347
request,
341-
meta: { collection: entityToJsonObject(collection) },
348+
meta: {
349+
collection: entityToJsonObject(collection),
350+
reason: request.body.reason,
351+
},
342352
});
343353
}
344354

plugins/qeta-backend/src/service/routes/helpers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Router } from 'express';
22
import {
3+
DeleteMetadataSchema,
34
DraftQuestionSchema,
45
EntitiesQuerySchema,
56
RouteOptions,
@@ -380,7 +381,8 @@ export const helperRoutes = (router: Router, options: RouteOptions) => {
380381

381382
router.delete('/tags/:tag', async (request, response) => {
382383
const tagId = Number.parseInt(request.params.tag, 10);
383-
if (Number.isNaN(tagId)) {
384+
const validateRequestBody = ajv.compile(DeleteMetadataSchema);
385+
if (Number.isNaN(tagId) || !validateRequestBody(request.body)) {
384386
response.status(400).send({ errors: 'Invalid tag id', type: 'body' });
385387
return;
386388
}
@@ -398,6 +400,7 @@ export const helperRoutes = (router: Router, options: RouteOptions) => {
398400
request,
399401
meta: {
400402
tagId,
403+
reason: request.body.reason,
401404
},
402405
});
403406
}

plugins/qeta-backend/src/service/routes/posts.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import addFormats from 'ajv-formats';
1919
import {
2020
CommentSchema,
21+
DeleteMetadataSchema,
2122
PostSchema,
2223
PostsQuerySchema,
2324
RouteOptions,
@@ -763,7 +764,13 @@ export const postsRoutes = (router: Router, options: RouteOptions) => {
763764
router.delete('/posts/:id', async (request, response) => {
764765
const ret = await getPostAndCheckStatus(request, response, false, true);
765766
if (!ret) return;
766-
const { post } = ret;
767+
const validateRequestBody = ajv.compile(DeleteMetadataSchema);
768+
if (!validateRequestBody(request.body)) {
769+
response.status(400).send({ errors: 'Invalid data', type: 'body' });
770+
return;
771+
}
772+
773+
const { post, username } = ret;
767774

768775
await permissionMgr.authorize(request, qetaDeletePostPermission, {
769776
resource: post,
@@ -778,6 +785,9 @@ export const postsRoutes = (router: Router, options: RouteOptions) => {
778785
deleted = await database.deletePost(post.id, true);
779786
} else {
780787
deleted = await database.deletePost(post.id);
788+
if (deleted) {
789+
notificationMgr.onPostDelete(username, post, request.body.reason);
790+
}
781791
}
782792

783793
if (deleted) {
@@ -786,6 +796,7 @@ export const postsRoutes = (router: Router, options: RouteOptions) => {
786796
eventPayload: {
787797
post,
788798
author: post.author,
799+
reason: request.body.reason,
789800
},
790801
metadata: { action: 'delete_post' },
791802
});
@@ -794,7 +805,7 @@ export const postsRoutes = (router: Router, options: RouteOptions) => {
794805
eventId: 'delete-post',
795806
severityLevel: 'medium',
796807
request,
797-
meta: { post: entityToJsonObject(post) },
808+
meta: { post: entityToJsonObject(post), reason: request.body.reason },
798809
});
799810
}
800811

plugins/qeta-backend/src/service/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,18 @@ export const PostAnswerSchema: JSONSchemaType<AnswerQuestion> = {
360360
additionalProperties: false,
361361
};
362362

363+
export interface DeleteMetadata {
364+
reason?: string;
365+
}
366+
367+
export const DeleteMetadataSchema: JSONSchemaType<DeleteMetadata> = {
368+
type: 'object',
369+
properties: {
370+
reason: { type: 'string', minLength: 1, nullable: true },
371+
},
372+
additionalProperties: false,
373+
};
374+
363375
export interface Comment {
364376
content: string;
365377
user?: string;

plugins/qeta-common/src/api/QetaApi.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,11 +352,16 @@ export interface QetaApi {
352352
requestOptions?: RequestOptions,
353353
): Promise<AnswerResponse>;
354354

355-
deletePost(postId: number, requestOptions?: RequestOptions): Promise<boolean>;
355+
deletePost(
356+
postId: number,
357+
reason?: string,
358+
requestOptions?: RequestOptions,
359+
): Promise<boolean>;
356360

357361
deleteAnswer(
358362
postId: number | string,
359363
id: number,
364+
reason?: string,
360365
requestOptions?: RequestOptions,
361366
): Promise<boolean>;
362367

@@ -403,7 +408,11 @@ export interface QetaApi {
403408
getFollowedTags(requestOptions?: RequestOptions): Promise<UserTagsResponse>;
404409
followTag(tag: string, requestOptions?: RequestOptions): Promise<boolean>;
405410
unfollowTag(tag: string, requestOptions?: RequestOptions): Promise<boolean>;
406-
deleteTag(id: number, requestOptions?: RequestOptions): Promise<boolean>;
411+
deleteTag(
412+
id: number,
413+
reason?: string,
414+
requestOptions?: RequestOptions,
415+
): Promise<boolean>;
407416

408417
getFollowedEntities(
409418
requestOptions?: RequestOptions,
@@ -455,6 +464,7 @@ export interface QetaApi {
455464
): Promise<CollectionResponse>;
456465
deleteCollection(
457466
id?: number,
467+
reason?: string,
458468
requestOptions?: RequestOptions,
459469
): Promise<boolean>;
460470
addPostToCollection(

0 commit comments

Comments
 (0)