diff --git a/app/components/ReportOverlay.tsx b/app/components/ReportOverlay.tsx index ff389af5..472237e5 100644 --- a/app/components/ReportOverlay.tsx +++ b/app/components/ReportOverlay.tsx @@ -14,24 +14,32 @@ import { useSnackbar } from './Snackbar'; import { Button } from './Buttons'; import { GoogleLinkButton, GoogleSignInButton } from './GoogleButtons'; -export const CommentReportV = t.type({ - /** comment id */ - cid: t.string, - /** comment author display name */ - cn: t.string, - /** comment text */ - ct: t.string, - /** puzzle id */ - pid: t.string, - /** reporter user id */ - u: t.string, - /** reporter notes */ - n: t.string, - /** report timestamp */ - t: timestamp, - /** handled? */ - h: t.boolean, -}); +export const CommentReportV = t.intersection([ + t.type({ + /** comment id */ + cid: t.string, + /** comment author display name */ + cn: t.string, + /** comment text */ + ct: t.string, + /** puzzle id */ + pid: t.string, + /** reporter user id */ + u: t.string, + /** reporter notes */ + n: t.string, + /** report timestamp */ + t: timestamp, + /** handled? */ + h: t.boolean, + }), + t.partial({ + /** comment author id */ + ca: t.string, + /** deleted? */ + d: t.boolean, + }), +]); type CommentReportT = t.TypeOf; export const ReportOverlay = (props: { @@ -57,6 +65,7 @@ export const ReportOverlay = (props: { const report: CommentReportT = { cid: props.comment.id, cn: props.comment.authorDisplayName, + ca: props.comment.authorId, ct: props.comment.commentText, pid: props.puzzleId, u: uid, diff --git a/app/lib/comments.ts b/app/lib/comments.ts index 9051a690..f3d30eab 100644 --- a/app/lib/comments.ts +++ b/app/lib/comments.ts @@ -1,10 +1,12 @@ import { + CommentDeletionWithIdT, CommentForModerationWithIdT, CommentWithRepliesT, DBPuzzleT, DBPuzzleV, } from './dbtypes'; import { getFromDB } from './dbUtils'; +import { getCollection, toFirestore } from './firebaseAdminWrapper'; function findCommentById( comments: CommentWithRepliesT[], @@ -26,61 +28,88 @@ function findCommentById( export async function moderateComments( commentsForModeration: CommentForModerationWithIdT[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - deleteCfm: (commentId: string) => Promise, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - updatePuzzle: (puzzleId: string, update: any) => Promise + deletions: CommentDeletionWithIdT[] ) { + // Puzzles cache so we only load each once const puzzles: Record = {}; - if (commentsForModeration.length) { - for (const comment of commentsForModeration) { - delete comment.approved; - delete comment.needsModeration; - if (comment.rejected) { - comment.deleted = true; - comment.removed = true; + async function puzzleFromCache(pid: string): Promise { + const fromCache = puzzles[pid]; + if (fromCache) { + return fromCache; + } else { + try { + const puzzle = await getFromDB('c', pid, DBPuzzleV); + puzzles[pid] = puzzle; + return puzzle; + } catch { + return null; } - delete comment.rejected; - let puzzle: DBPuzzleT | null = null; - const fromCache = puzzles[comment.pid]; - if (fromCache) { - puzzle = fromCache; - } else { - try { - puzzle = await getFromDB('c', comment.pid, DBPuzzleV); - puzzles[comment.pid] = puzzle; - } catch { - puzzle = null; - } + } + } + + // First merge comments + for (const comment of commentsForModeration) { + delete comment.approved; + delete comment.needsModeration; + if (comment.rejected) { + comment.deleted = true; + comment.removed = true; + } + delete comment.rejected; + const puzzle = await puzzleFromCache(comment.pid); + if (puzzle) { + if (puzzle.cs === undefined) { + puzzle.cs = []; } - if (puzzle) { - if (puzzle.cs === undefined) { - puzzle.cs = []; + if (comment.rt === null) { + if (!puzzle.cs.find((existing) => existing.i === comment.i)) { + puzzle.cs.push(comment); } - if (comment.rt === null) { - if (!puzzle.cs.find((existing) => existing.i === comment.i)) { - puzzle.cs.push(comment); + } else { + const parent = findCommentById(puzzle.cs, comment.rt); + if (parent === null) { + throw new Error('parent comment not found'); + } + if (parent.r) { + if (!parent.r.find((existing) => existing.i === comment.i)) { + parent.r.push(comment); } } else { - const parent = findCommentById(puzzle.cs, comment.rt); - if (parent === null) { - throw new Error('parent comment not found'); - } - if (parent.r) { - if (!parent.r.find((existing) => existing.i === comment.i)) { - parent.r.push(comment); - } - } else { - parent.r = [comment]; - } + parent.r = [comment]; + } + } + } + await getCollection('cfm').doc(comment.i).delete(); + } + + // Now handle deletions + for (const deletion of deletions) { + const puzzle = await puzzleFromCache(deletion.pid); + if (puzzle && puzzle.cs?.length) { + const comment = findCommentById(puzzle.cs, deletion.cid); + if (comment && comment.a === deletion.a) { + comment.deleted = true; + if (deletion.removed) { + comment.removed = true; + } + + // Delete any notifications generated by that comment + const notificationsRes = await getCollection('n') + .where('p', '==', deletion.pid) + .where('c', '==', deletion.cid) + .get(); + for (const notification of notificationsRes.docs) { + await getCollection('n').doc(notification.id).delete(); } } - await deleteCfm(comment.i); } + await getCollection('deleteComment').doc(deletion.i).delete(); } - // Now we've merged in all the comments, so update the puzzles: + // Now we've merged and deleted, so update the puzzles: for (const [puzzleId, dbPuzzle] of Object.entries(puzzles)) { - await updatePuzzle(puzzleId, { cs: dbPuzzle.cs }); + await getCollection('c') + .doc(puzzleId) + .update(toFirestore({ cs: dbPuzzle.cs })); } } diff --git a/app/lib/dbtypes.ts b/app/lib/dbtypes.ts index 428b471c..b5315505 100644 --- a/app/lib/dbtypes.ts +++ b/app/lib/dbtypes.ts @@ -82,6 +82,27 @@ export type CommentForModerationWithIdT = t.TypeOf< typeof CommentForModerationWithIdV >; +export const CommentDeletionV = t.type({ + /** puzzle id */ + pid: t.string, + /** comment id */ + cid: t.string, + /** author id */ + a: t.string, + /** was the deletion done by a moderator? */ + removed: t.boolean, +}); +export type CommentDeletionT = t.TypeOf; + +export const CommentDeletionWithIdV = t.intersection([ + CommentDeletionV, + t.type({ + /** deletion id */ + i: t.string, + }), +]); +export type CommentDeletionWithIdT = t.TypeOf; + const DBPuzzleMandatoryV = t.type({ /** author's user id */ a: t.string, diff --git a/app/pages/admin.tsx b/app/pages/admin.tsx index d35eba3c..2b4a254b 100644 --- a/app/pages/admin.tsx +++ b/app/pages/admin.tsx @@ -15,6 +15,7 @@ import { CommentForModerationWithIdV, DonationsListV, DBPuzzleWithIdV, + CommentDeletionT, } from '../lib/dbtypes'; import { getCollection, @@ -30,6 +31,7 @@ import { useDocumentDataOnce, } from 'react-firebase-hooks/firestore'; import { + addDoc, arrayUnion, deleteField, doc, @@ -305,6 +307,34 @@ export default requiresAdmin(() => { puzzle -{' '} {rc.pid} +