Skip to content

Commit

Permalink
Functionality to delete reported comments. #324
Browse files Browse the repository at this point in the history
  • Loading branch information
mdirolf committed Apr 5, 2024
1 parent f94f2f9 commit 1471a8f
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 71 deletions.
45 changes: 27 additions & 18 deletions app/components/ReportOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof CommentReportV>;

export const ReportOverlay = (props: {
Expand All @@ -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,
Expand Down
115 changes: 72 additions & 43 deletions app/lib/comments.ts
Original file line number Diff line number Diff line change
@@ -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[],
Expand All @@ -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<any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updatePuzzle: (puzzleId: string, update: any) => Promise<any>
deletions: CommentDeletionWithIdT[]
) {
// Puzzles cache so we only load each once
const puzzles: Record<string, DBPuzzleT> = {};
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<DBPuzzleT | null> {
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 }));
}
}
21 changes: 21 additions & 0 deletions app/lib/dbtypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof CommentDeletionV>;

export const CommentDeletionWithIdV = t.intersection([
CommentDeletionV,
t.type({
/** deletion id */
i: t.string,
}),
]);
export type CommentDeletionWithIdT = t.TypeOf<typeof CommentDeletionWithIdV>;

const DBPuzzleMandatoryV = t.type({
/** author's user id */
a: t.string,
Expand Down
30 changes: 30 additions & 0 deletions app/pages/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
CommentForModerationWithIdV,
DonationsListV,
DBPuzzleWithIdV,
CommentDeletionT,
} from '../lib/dbtypes';
import {
getCollection,
Expand All @@ -30,6 +31,7 @@ import {
useDocumentDataOnce,
} from 'react-firebase-hooks/firestore';
import {
addDoc,
arrayUnion,
deleteField,
doc,
Expand Down Expand Up @@ -305,6 +307,34 @@ export default requiresAdmin(() => {
<Link href={`/crosswords/${rc.pid}`}>puzzle</Link> -{' '}
{rc.pid}
</div>
<button
disabled={rc.d || !rc.ca}
css={{ marginRight: '2em' }}
onClick={logAsyncErrors(async () => {
if (!rc.ca) {
return;
}
const deletion: CommentDeletionT = {
pid: rc.pid,
cid: rc.cid,
a: rc.ca,
removed: true,
};
await addDoc(
getCollection('deleteComment'),
deletion
).then(() => {
console.log('delete comment');
});
await updateDoc(getDocRef('cr', `${rc.cid}-${rc.u}`), {
d: true,
}).then(() => {
console.log('marked as deleted');
});
})}
>
Delete Comment
</button>
<button
onClick={logAsyncErrors(async () => {
await updateDoc(getDocRef('cr', `${rc.cid}-${rc.u}`), {
Expand Down
28 changes: 18 additions & 10 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import {
CommentForModerationV,
AdminSettingsV,
CommentForModerationWithIdT,
CommentDeletionV,
CommentDeletionWithIdT,
} from '../../app/lib/dbtypes';

import {
getCollection,
mapEachResult,
toFirestore,
} from '../../app/lib/firebaseAdminWrapper';
import { Timestamp } from '../../app/lib/timestamp';

Expand Down Expand Up @@ -66,30 +67,37 @@ export const autoModerator = functions.pubsub
// If it's been approved or rejected already then we can automod it
if (cfm.approved || cfm.rejected) {
filtered.push(cfm);
break;
continue;
}
// We've already automodded it and it needs approval
if (cfm.needsModeration) {
break;
continue;
}
// Check if we need to send it off for manual moderation
if (settings.noAuto.includes(cfm.a) || checkSpam(cfm.c)) {
await getCollection('cfm').doc(cfm.i).update({ needsModeration: true });
break;
continue;
}
// Otherwise we are good to automod it
filtered.push(cfm);
}

console.log(`done filtering, automoderating ${filtered.length} of them`);
console.log(
`done filtering, automoderating ${filtered.length} of them. Getting deletions`
);

await moderateComments(
filtered,
(cid) => getCollection('cfm').doc(cid).delete(),
(puzzleId, update) =>
getCollection('c').doc(puzzleId).update(toFirestore(update))
const deletions: CommentDeletionWithIdT[] = await mapEachResult(
getCollection('deleteComment'),
CommentDeletionV,
(cd, docId) => {
return { ...cd, i: docId };
}
);

console.log(`Got ${deletions.length} deletions`);

await moderateComments(filtered, deletions);

console.log('done');
return;
});
Expand Down

0 comments on commit 1471a8f

Please sign in to comment.