diff --git a/.dockerignore b/.dockerignore index 7d259f4a06..0a64dffc37 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,15 +1,20 @@ # tests are not run in the docker container. __tests__ +**/__tests__ +**/coverage coverage # we won't use the .git folder in production. .git +.github # don't include the dependancies node_modules +**/node_modules # don't include any logs npm-debug.log* +**/npm-debug.log* # don't include any yarn files yarn-error.log @@ -17,14 +22,30 @@ yarn.lock # don't include any OS/editor files .env +**/.env .idea/ .vs +.vscode + .docz +**/.docz + *.swp +**/*.swp + *.DS_STORE +*.DS_Store +**/*.DS_STORE +**/*.DS_Store # don't include any generated files dist +**/dist + *.css.d.ts +**/*.css.d.ts + __generated__ -docs/.next \ No newline at end of file +**/__generated__ + +**/.next \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b5e3077a3c..d59ade4a9a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -37,8 +37,8 @@ Any related Github issue should be linked by adding its URL to this section. -## Where any tests migrated to React Testing Library? +## Were any tests migrated to React Testing Library? + ## Table of Contents - [Request Signing](#request-signing) diff --git a/server/package-lock.json b/server/package-lock.json index edf78558fa..1b70ccebd5 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@coralproject/talk", - "version": "8.5.0", + "version": "8.6.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@coralproject/talk", - "version": "8.5.0", + "version": "8.6.2", "license": "Apache-2.0", "dependencies": { "@ampproject/toolbox-cache-url": "^2.9.0", diff --git a/server/package.json b/server/package.json index 367951ea5f..6a1ab595b4 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@coralproject/talk", - "version": "8.5.0", + "version": "8.6.2", "author": "The Coral Project", "homepage": "https://coralproject.net/", "sideEffects": [ @@ -431,4 +431,4 @@ "not OperaMini all", "not dead" ] -} \ No newline at end of file +} diff --git a/server/scripts/precommitLint.js b/server/scripts/precommitLint.js index 955acfb200..dd178222ef 100644 --- a/server/scripts/precommitLint.js +++ b/server/scripts/precommitLint.js @@ -26,12 +26,12 @@ sgf((err, results) => { const eslintFiles = []; for (const item of results) { - const { filename } = item; + const { filename, status } = item; // only include valid, filtered extensions // this is primarily to keep eslint rampaging // over non-source files - if (!matchesExtension(extensions, filename)) { + if (!matchesExtension(extensions, filename) || status === "Deleted") { continue; } diff --git a/server/src/core/server/app/handlers/api/account/notifications.ts b/server/src/core/server/app/handlers/api/account/notifications.ts index ff7daccabb..dbec9512c3 100644 --- a/server/src/core/server/app/handlers/api/account/notifications.ts +++ b/server/src/core/server/app/handlers/api/account/notifications.ts @@ -2,7 +2,7 @@ import { AppOptions } from "coral-server/app"; import { RequestLimiter } from "coral-server/app/request/limiter"; import { updateUserNotificationSettings } from "coral-server/models/user"; import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt"; -import { verifyUnsubscribeTokenString } from "coral-server/services/notifications/categories/unsubscribe"; +import { verifyUnsubscribeTokenString } from "coral-server/services/notifications/email/categories/unsubscribe"; import { RequestHandler, TenantCoralRequest } from "coral-server/types/express"; export type UnsubscribeCheckOptions = Pick< diff --git a/server/src/core/server/app/handlers/api/dsaReport.ts b/server/src/core/server/app/handlers/api/dsaReport.ts new file mode 100644 index 0000000000..aeadc9ea66 --- /dev/null +++ b/server/src/core/server/app/handlers/api/dsaReport.ts @@ -0,0 +1,44 @@ +import { AppOptions } from "coral-server/app"; +import { RequestLimiter } from "coral-server/app/request/limiter"; +import { retrieveDSAReport } from "coral-server/models/dsaReport"; +import { sendReportDownload } from "coral-server/services/dsaReports"; +import { RequestHandler, TenantCoralRequest } from "coral-server/types/express"; + +type AdminDownloadOptions = Pick< + AppOptions, + "mongo" | "i18n" | "redis" | "config" +>; + +export const reportDownloadHandler = ({ + mongo, + i18n, + redis, + config, +}: AdminDownloadOptions): RequestHandler => { + const ipLimiter = new RequestLimiter({ + redis, + ttl: "10m", + max: 10, + prefix: "ip", + config, + }); + return async (req, res, next) => { + try { + // Rate limit based on the IP address and user agent. + await ipLimiter.test(req, req.ip); + + const { tenant, now } = req.coral; + const { reportID } = req.query; + + const report = await retrieveDSAReport(mongo, tenant.id, reportID); + + if (!report) { + return res.sendStatus(400); + } + + await sendReportDownload(res, mongo, i18n, tenant, report, now); + } catch (err) { + return next(err); + } + }; +}; diff --git a/server/src/core/server/app/handlers/api/index.ts b/server/src/core/server/app/handlers/api/index.ts index 5629b498b8..52f9af2326 100644 --- a/server/src/core/server/app/handlers/api/index.ts +++ b/server/src/core/server/app/handlers/api/index.ts @@ -1,6 +1,7 @@ export * from "./account"; export * from "./auth"; export * from "./dashboard"; +export * from "./dsaReport"; export * from "./externalMedia"; export * from "./health"; export * from "./install"; diff --git a/server/src/core/server/app/handlers/api/story/active.ts b/server/src/core/server/app/handlers/api/story/active.ts index bf0ba6b44b..f6575d3b17 100644 --- a/server/src/core/server/app/handlers/api/story/active.ts +++ b/server/src/core/server/app/handlers/api/story/active.ts @@ -14,11 +14,13 @@ export type Options = Pick; const ActiveStoriesQuerySchema = Joi.object().keys({ callback: Joi.string().allow("").optional(), siteID: Joi.string().required(), + count: Joi.number().optional().max(999), }); interface ActiveStoriesQuery { callback: string; siteID: string; + count: number; } /** @@ -44,7 +46,7 @@ export const activeJSONPHandler = const { tenant, now } = req.coral; // Ensure we have a siteID on the query. - const { siteID }: ActiveStoriesQuery = validate( + const { siteID, count }: ActiveStoriesQuery = validate( ActiveStoriesQuerySchema, req.query ); @@ -61,7 +63,7 @@ export const activeJSONPHandler = mongo, tenant.id, siteID, - 5, + count ?? 5, start, now ); diff --git a/server/src/core/server/app/handlers/api/story/count.ts b/server/src/core/server/app/handlers/api/story/count.ts index 2a30d906f6..9356df22b6 100644 --- a/server/src/core/server/app/handlers/api/story/count.ts +++ b/server/src/core/server/app/handlers/api/story/count.ts @@ -39,7 +39,7 @@ interface StoryCountJSONPQuery { ref: string; } -function getTextHTML( +export function getTextHTML( tenant: Readonly, storyMode: GQLSTORY_MODE | undefined | null, i18n: I18n, diff --git a/server/src/core/server/app/middleware/commentEmbedWhitelisted.ts b/server/src/core/server/app/middleware/commentEmbedWhitelisted.ts index 0ccb3732e7..0f6fd29f1f 100644 --- a/server/src/core/server/app/middleware/commentEmbedWhitelisted.ts +++ b/server/src/core/server/app/middleware/commentEmbedWhitelisted.ts @@ -9,7 +9,7 @@ import { AppOptions } from ".."; import { getRequesterOrigin } from "../helpers"; export const commentEmbedWhitelisted = - ({ mongo }: Pick): RequestHandler => + ({ mongo }: Pick, oembedAPI = false): RequestHandler => async (req, res, next) => { // First try to get the commentID from the query params let { commentID } = req.query; @@ -37,6 +37,13 @@ export const commentEmbedWhitelisted = origin = req.header("Origin"); } if (origin) { + // if oEmbed API call, we also check oEmbed allowed origins on tenant + if ( + oembedAPI && + tenant.embeddedComments?.oEmbedAllowedOrigins.includes(origin) + ) { + return next(); + } if (site.allowedOrigins.includes(origin)) { return next(); } diff --git a/server/src/core/server/app/router/api/dsaReport.ts b/server/src/core/server/app/router/api/dsaReport.ts new file mode 100644 index 0000000000..06c8211353 --- /dev/null +++ b/server/src/core/server/app/router/api/dsaReport.ts @@ -0,0 +1,15 @@ +import { AppOptions } from "coral-server/app"; +import { reportDownloadHandler } from "coral-server/app/handlers"; +import { userLimiterMiddleware } from "coral-server/app/middleware/userLimiter"; + +import { createAPIRouter } from "./helpers"; + +export function createDSAReportRouter(app: AppOptions) { + const router = createAPIRouter({ cacheDuration: "30s" }); + + router.use(userLimiterMiddleware(app)); + + router.get("/reportDownload", reportDownloadHandler(app)); + + return router; +} diff --git a/server/src/core/server/app/router/api/index.ts b/server/src/core/server/app/router/api/index.ts index 34bc5793c8..ededd2e276 100644 --- a/server/src/core/server/app/router/api/index.ts +++ b/server/src/core/server/app/router/api/index.ts @@ -23,12 +23,16 @@ import { roleMiddleware, tenantMiddleware, } from "coral-server/app/middleware"; -import { STAFF_ROLES } from "coral-server/models/user/constants"; +import { + MODERATOR_ROLES, + STAFF_ROLES, +} from "coral-server/models/user/constants"; import { createNewAccountRouter } from "./account"; import { createNewAuthRouter } from "./auth"; import { createCommentRouter } from "./comment"; import { createDashboardRouter } from "./dashboard"; +import { createDSAReportRouter } from "./dsaReport"; import { createNewInstallRouter } from "./install"; import { createRemoteMediaRouter } from "./remoteMedia"; import { createStoryRouter } from "./story"; @@ -81,6 +85,14 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) { // Attach the GraphQL router (which will be mounted on the same path). router.use(apolloGraphQLMiddleware(app)); + router.use( + "/dsaReport", + authenticate(options.passport), + loggedInMiddleware, + roleMiddleware(MODERATOR_ROLES), + createDSAReportRouter(app) + ); + router.use( "/dashboard", authenticate(options.passport), @@ -97,7 +109,7 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) { router.get("/oembed", cspSiteMiddleware(app), oembedHandler(app)); router.get( "/services/oembed", - commentEmbedWhitelisted(app), + commentEmbedWhitelisted(app, true), cors(createCommentEmbedCorsOptionsDelegate(app.mongo)), oembedProviderHandler(app) ); diff --git a/server/src/core/server/app/router/client.ts b/server/src/core/server/app/router/client.ts index f0aea20436..7f3335d2ce 100644 --- a/server/src/core/server/app/router/client.ts +++ b/server/src/core/server/app/router/client.ts @@ -143,11 +143,14 @@ const populateStaticConfig = (staticConfig: StaticConfig, req: Request) => { req.coral.tenant?.featureFlags?.filter(validFeatureFlagsFilter(req.user)) || []; const flattenReplies = req.coral.tenant?.flattenReplies || false; + const dsaFeaturesEnabled = req.coral.tenant?.dsa?.enabled ?? false; + return { ...staticConfig, featureFlags, tenantDomain: req.coral.tenant?.domain, flattenReplies, + dsaFeaturesEnabled, }; }; diff --git a/server/src/core/server/cron/accountDeletion.ts b/server/src/core/server/cron/accountDeletion.ts index 3da626d01b..e57be6a35b 100644 --- a/server/src/core/server/cron/accountDeletion.ts +++ b/server/src/core/server/cron/accountDeletion.ts @@ -2,6 +2,7 @@ import { Config } from "coral-server/config"; import { MongoContext } from "coral-server/data/context"; import { retrieveLockedUserScheduledForDeletion } from "coral-server/models/user"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; +import { I18n } from "coral-server/services/i18n"; import { AugmentedRedis } from "coral-server/services/redis"; import { TenantCache } from "coral-server/services/tenant/cache"; import { deleteUser } from "coral-server/services/users/delete"; @@ -16,6 +17,7 @@ interface Options { mongo: MongoContext; redis: AugmentedRedis; config: Config; + i18n: I18n; mailerQueue: MailerQueue; tenantCache: TenantCache; } @@ -39,6 +41,7 @@ const deleteScheduledAccounts: ScheduledJobCommand = async ({ mongo, redis, config, + i18n, mailerQueue, tenantCache, }) => { @@ -64,7 +67,16 @@ const deleteScheduledAccounts: ScheduledJobCommand = async ({ log.info({ userID: user.id }, "deleting user"); - await deleteUser(mongo, redis, config, user.id, tenant.id, now); + await deleteUser( + mongo, + redis, + config, + i18n, + user.id, + tenant.id, + now, + tenant.dsa?.enabled + ); // If the user has an email, then send them a confirmation that their account // was deleted. diff --git a/server/src/core/server/cron/index.ts b/server/src/core/server/cron/index.ts index fa5c99d706..7013c4ca95 100644 --- a/server/src/core/server/cron/index.ts +++ b/server/src/core/server/cron/index.ts @@ -2,6 +2,7 @@ import { Config } from "coral-server/config"; import { MongoContext } from "coral-server/data/context"; import { ArchiverQueue } from "coral-server/queue/tasks/archiver"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; +import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; import { AugmentedRedis } from "coral-server/services/redis"; import { TenantCache } from "coral-server/services/tenant/cache"; @@ -20,6 +21,7 @@ interface Options { mongo: MongoContext; redis: AugmentedRedis; config: Config; + i18n: I18n; mailerQueue: MailerQueue; archiverQueue: ArchiverQueue; signingConfig: JWTSigningConfig; diff --git a/server/src/core/server/cron/notificationDigesting.ts b/server/src/core/server/cron/notificationDigesting.ts index 993dd1e8b3..a6f5c6a317 100644 --- a/server/src/core/server/cron/notificationDigesting.ts +++ b/server/src/core/server/cron/notificationDigesting.ts @@ -5,7 +5,7 @@ import { MongoContext } from "coral-server/data/context"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates"; import { JWTSigningConfig } from "coral-server/services/jwt"; -import NotificationContext from "coral-server/services/notifications/context"; +import NotificationContext from "coral-server/services/notifications/email/context"; import { TenantCache } from "coral-server/services/tenant/cache"; import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types"; diff --git a/server/src/core/server/data/context.ts b/server/src/core/server/data/context.ts index 959689c8db..6811b77065 100644 --- a/server/src/core/server/data/context.ts +++ b/server/src/core/server/data/context.ts @@ -4,9 +4,11 @@ import { Config } from "coral-server/config"; import { CommentAction } from "coral-server/models/action/comment"; import { CommentModerationAction } from "coral-server/models/action/moderation/comment"; import { Comment } from "coral-server/models/comment"; +import { DSAReport } from "coral-server/models/dsaReport"; import { createCollection } from "coral-server/models/helpers"; import { Invite } from "coral-server/models/invite"; import { MigrationRecord } from "coral-server/models/migration"; +import { Notification } from "coral-server/models/notifications/notification"; import { PersistedQuery } from "coral-server/models/queries"; import { SeenComments } from "coral-server/models/seenComments/seenComments"; import { Site } from "coral-server/models/site"; @@ -35,6 +37,8 @@ export interface MongoContext { Readonly >; seenComments(): Collection>; + dsaReports(): Collection>; + notifications(): Collection>; } export class MongoContextImpl implements MongoContext { @@ -83,6 +87,12 @@ export class MongoContextImpl implements MongoContext { public seenComments(): Collection> { return createCollection("seenComments")(this.live); } + public dsaReports(): Collection> { + return createCollection("dsaReports")(this.live); + } + public notifications(): Collection> { + return createCollection("notifications")(this.live); + } public archivedComments(): Collection> { if (!this.archive) { throw new Error( diff --git a/server/src/core/server/errors/index.ts b/server/src/core/server/errors/index.ts index e5b114da21..5c31aa58bb 100644 --- a/server/src/core/server/errors/index.ts +++ b/server/src/core/server/errors/index.ts @@ -322,6 +322,15 @@ export class DuplicateEmailError extends CoralError { } } +export class DuplicateDSAReportError extends CoralError { + constructor(reportID: string) { + super({ + code: ERROR_CODES.DUPLICATE_DSA_REPORT, + context: { pub: { reportID } }, + }); + } +} + export class DuplicateEmailDomainError extends CoralError { constructor(emailDomain: string) { super({ diff --git a/server/src/core/server/errors/translations.ts b/server/src/core/server/errors/translations.ts index 8b4cc7f204..f1f1594b8f 100644 --- a/server/src/core/server/errors/translations.ts +++ b/server/src/core/server/errors/translations.ts @@ -10,6 +10,7 @@ export const ERROR_TRANSLATIONS: Record = { COMMENTING_DISABLED: "error-commentingDisabled", DUPLICATE_EMAIL: "error-duplicateEmail", DUPLICATE_EMAIL_DOMAIN: "error-duplicateEmailDomain", + DUPLICATE_DSA_REPORT: "error-duplicateDSAReport", DUPLICATE_STORY_ID: "error-duplicateStoryID", DUPLICATE_STORY_URL: "error-duplicateStoryURL", DUPLICATE_FLAIR_BADGE: "error-duplicateFlairBadge", diff --git a/server/src/core/server/events/listeners/notifier.ts b/server/src/core/server/events/listeners/notifier.ts index 6ecd044140..a8dfb80142 100644 --- a/server/src/core/server/events/listeners/notifier.ts +++ b/server/src/core/server/events/listeners/notifier.ts @@ -1,5 +1,5 @@ import { NotifierQueue } from "coral-server/queue/tasks/notifier"; -import { categories } from "coral-server/services/notifications/categories"; +import { categories } from "coral-server/services/notifications/email/categories"; import { CommentFeaturedCoralEventPayload, diff --git a/server/src/core/server/events/listeners/slack/publishEvent.ts b/server/src/core/server/events/listeners/slack/publishEvent.ts index 02f5b3c042..e2165934f3 100644 --- a/server/src/core/server/events/listeners/slack/publishEvent.ts +++ b/server/src/core/server/events/listeners/slack/publishEvent.ts @@ -103,9 +103,12 @@ export default class SlackPublishEvent { ); const commentLink = getURLWithCommentID(this.story.url, this.comment.id); - const body = getHTMLPlainText(getLatestRevision(this.comment).body); + // We truncate to less than 3000 characters to stay under Slack limits + const truncatedBody = getHTMLPlainText( + getLatestRevision(this.comment).body + ).slice(0, 2999); return { - text: body, + text: truncatedBody, blocks: [ { type: "section", @@ -121,7 +124,7 @@ export default class SlackPublishEvent { block_id: "body-block", text: { type: "plain_text", - text: body, + text: truncatedBody, }, }, { diff --git a/server/src/core/server/events/listeners/webhook.ts b/server/src/core/server/events/listeners/webhook.ts index 2467ae2f36..c42a3373d7 100644 --- a/server/src/core/server/events/listeners/webhook.ts +++ b/server/src/core/server/events/listeners/webhook.ts @@ -17,6 +17,7 @@ export class WebhookCoralEventListener CoralEventType.STORY_CREATED, CoralEventType.COMMENT_CREATED, CoralEventType.COMMENT_REPLY_CREATED, + CoralEventType.COMMENT_LEFT_MODERATION_QUEUE, ]; private readonly queue: WebhookQueue; diff --git a/server/src/core/server/graph/context.ts b/server/src/core/server/graph/context.ts index d68c3feca5..4a27d6e557 100644 --- a/server/src/core/server/graph/context.ts +++ b/server/src/core/server/graph/context.ts @@ -24,6 +24,7 @@ import { WordListService } from "coral-server/services/comments/pipeline/phases/ import { ErrorReporter } from "coral-server/services/errors"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { TenantCache } from "coral-server/services/tenant/cache"; import { Request } from "coral-server/types/express"; @@ -103,6 +104,8 @@ export default class GraphContext { public readonly wordList: WordListService; + public readonly notifications: InternalNotificationContext; + constructor(options: GraphContextOptions) { this.id = options.id || uuid(); this.now = options.now || new Date(); @@ -151,5 +154,11 @@ export default class GraphContext { this.disableCaching, this.config.get("redis_cache_expiry") / 1000 ); + + this.notifications = new InternalNotificationContext( + this.mongo, + this.i18n, + this.logger + ); } } diff --git a/server/src/core/server/graph/loaders/Comments.ts b/server/src/core/server/graph/loaders/Comments.ts index bcf6ef4b2b..003471e8b5 100644 --- a/server/src/core/server/graph/loaders/Comments.ts +++ b/server/src/core/server/graph/loaders/Comments.ts @@ -58,7 +58,7 @@ const tagFilter = (tag?: GQLTAG): CommentConnectionInput["filter"] => { return {}; }; -const isRatingsAndReviews = ( +export const isRatingsAndReviews = ( tenant: Pick, story: Story ) => { @@ -68,7 +68,7 @@ const isRatingsAndReviews = ( ); }; -const isQA = (tenant: Pick, story: Story) => { +export const isQA = (tenant: Pick, story: Story) => { return ( hasFeatureFlag(tenant, GQLFEATURE_FLAG.ENABLE_QA) && story.settings.mode === GQLSTORY_MODE.QA @@ -170,6 +170,13 @@ const mapVisibleComment = (user?: Pick) => { }; }; +interface ActionPresenceArgs { + commentID: string; + isArchived: boolean; + isQA: boolean; + isRR: boolean; +} + /** * mapVisibleComments will map each comment an array to an array of Comment and * null. @@ -250,24 +257,33 @@ export default (ctx: GraphContext) => ({ isArchived ).then(primeCommentsFromConnection(ctx)); }, - retrieveMyActionPresence: new DataLoader( - (commentIDs: string[]) => { - if (!ctx.user) { - // This should only ever be accessed when a user is logged in. It should - // be safe to get the user here, but we'll throw an error anyways just - // in case. - throw new Error("can't get action presence of an undefined user"); - } - - return retrieveManyUserActionPresence( - ctx.mongo, - ctx.cache.commentActions, - ctx.tenant.id, - ctx.user.id, - commentIDs - ); + retrieveMyActionPresence: new DataLoader< + ActionPresenceArgs, + GQLActionPresence + >(async (args: ActionPresenceArgs[]) => { + if (!ctx.user) { + // This should only ever be accessed when a user is logged in. It should + // be safe to get the user here, but we'll throw an error anyways just + // in case. + throw new Error("can't get action presence of an undefined user"); } - ), + + const commentIDs = args.map((rd) => rd.commentID); + const hasArchivedData = args.some((rd) => rd.isArchived); + const hasRROrQA = args.some((rd) => rd.isQA || rd.isRR); + + const result = await retrieveManyUserActionPresence( + ctx.mongo, + ctx.cache.commentActions, + ctx.tenant.id, + ctx.user.id, + commentIDs, + !(hasRROrQA || hasArchivedData), + hasArchivedData + ); + + return result; + }), forUser: (userID: string, { first, orderBy, after }: UserToCommentsArgs) => retrieveCommentUserConnection(ctx.mongo, ctx.tenant.id, userID, { first: defaultTo(first, 10), diff --git a/server/src/core/server/graph/loaders/DSAReports.ts b/server/src/core/server/graph/loaders/DSAReports.ts new file mode 100644 index 0000000000..fad47e0d4a --- /dev/null +++ b/server/src/core/server/graph/loaders/DSAReports.ts @@ -0,0 +1,74 @@ +import DataLoader from "dataloader"; +import { defaultTo } from "lodash"; + +import { + DSAReportConnectionInput, + find, + retrieveDSAReportConnection, + retrieveDSAReportRelatedReportsConnection, +} from "coral-server/models/dsaReport"; + +import GraphContext from "../context"; +import { createManyBatchLoadFn } from "./util"; + +import { + DSAReportToRelatedReportsArgs, + GQLDSAREPORT_STATUS_FILTER, + GQLREPORT_SORT, + QueryToDsaReportsArgs, +} from "coral-server/graph/schema/__generated__/types"; + +type DSAReportConnectionFilterInput = DSAReportConnectionInput["filter"]; + +export interface FindDSAReportInput { + id: string; +} + +const statusFilter = ( + status?: GQLDSAREPORT_STATUS_FILTER[] +): DSAReportConnectionFilterInput => { + if (status) { + return { + status: { $in: status }, + }; + } + return {}; +}; + +export default (ctx: GraphContext) => ({ + connection: ({ first, after, orderBy, status }: QueryToDsaReportsArgs) => + retrieveDSAReportConnection(ctx.mongo, ctx.tenant.id, { + first: defaultTo(first, 20), + after, + orderBy: defaultTo(orderBy, GQLREPORT_SORT.CREATED_AT_DESC), + filter: { + // Merge the status filters into the query. + ...statusFilter(status), + }, + }), + dsaReport: new DataLoader( + createManyBatchLoadFn((input: FindDSAReportInput) => + find(ctx.mongo, ctx.tenant, input) + ), + { + cacheKeyFn: (input: FindDSAReportInput) => `${input.id}`, + cache: !ctx.disableCaching, + } + ), + relatedReports: ( + submissionID: string, + id: string, + { first, orderBy, after }: DSAReportToRelatedReportsArgs + ) => + retrieveDSAReportRelatedReportsConnection( + ctx.mongo, + ctx.tenant.id, + submissionID, + id, + { + first: defaultTo(first, 10), + orderBy: defaultTo(orderBy, GQLREPORT_SORT.CREATED_AT_DESC), + after, + } + ), +}); diff --git a/server/src/core/server/graph/loaders/Notifications.ts b/server/src/core/server/graph/loaders/Notifications.ts new file mode 100644 index 0000000000..de5bbbfce5 --- /dev/null +++ b/server/src/core/server/graph/loaders/Notifications.ts @@ -0,0 +1,23 @@ +import Context from "coral-server/graph/context"; +import { + hasNewNotifications, + NotificationsConnectionInput, + retrieveNotificationsConnection, +} from "coral-server/models/notifications/notification"; + +export default (ctx: Context) => ({ + connection: async ({ + ownerID, + first, + after, + }: NotificationsConnectionInput) => { + return await retrieveNotificationsConnection(ctx.mongo, ctx.tenant.id, { + ownerID, + first, + after, + }); + }, + hasNewNotifications: async (ownerID: string, lastSeen: Date) => { + return hasNewNotifications(ctx.tenant.id, ctx.mongo, ownerID, lastSeen); + }, +}); diff --git a/server/src/core/server/graph/loaders/index.ts b/server/src/core/server/graph/loaders/index.ts index 1b47829656..906ed2d51a 100644 --- a/server/src/core/server/graph/loaders/index.ts +++ b/server/src/core/server/graph/loaders/index.ts @@ -4,6 +4,8 @@ import Auth from "./Auth"; import CommentActions from "./CommentActions"; import CommentModerationActions from "./CommentModerationActions"; import Comments from "./Comments"; +import DSAReports from "./DSAReports"; +import Notifications from "./Notifications"; import SeenComments from "./SeenComments"; import Sites from "./Sites"; import Stories from "./Stories"; @@ -18,4 +20,6 @@ export default (ctx: Context) => ({ Users: Users(ctx), Sites: Sites(ctx), SeenComments: SeenComments(ctx), + Notifications: Notifications(ctx), + DSAReports: DSAReports(ctx), }); diff --git a/server/src/core/server/graph/mutators/Actions.ts b/server/src/core/server/graph/mutators/Actions.ts index 5a8c6757ba..1df55427a8 100644 --- a/server/src/core/server/graph/mutators/Actions.ts +++ b/server/src/core/server/graph/mutators/Actions.ts @@ -20,7 +20,9 @@ export const Actions = (ctx: GraphContext) => ({ ctx.redis, ctx.cache, ctx.config, + ctx.i18n, ctx.broker, + ctx.notifications, ctx.tenant, input.commentID, input.commentRevisionID, @@ -32,18 +34,20 @@ export const Actions = (ctx: GraphContext) => ({ rejectComment: async (input: GQLRejectCommentInput) => { // Validate that this user is allowed to moderate this comment await validateUserModerationScopes(ctx, ctx.user!, input); - return rejectComment( ctx.mongo, ctx.redis, ctx.cache, ctx.config, + ctx.i18n, ctx.broker, + ctx.notifications, ctx.tenant, input.commentID, input.commentRevisionID, ctx.user!.id, - ctx.now + ctx.now, + input.reason ); }, reviewCommentFlag: async (input: GQLReviewCommentFlagInput) => { diff --git a/server/src/core/server/graph/mutators/Comments.ts b/server/src/core/server/graph/mutators/Comments.ts index 0b7e4a9fbd..c7ae0749c4 100644 --- a/server/src/core/server/graph/mutators/Comments.ts +++ b/server/src/core/server/graph/mutators/Comments.ts @@ -7,6 +7,7 @@ import { addTag, removeTag } from "coral-server/services/comments"; import { createDontAgree, createFlag, + createIllegalContent, createReaction, removeDontAgree, removeReaction, @@ -28,9 +29,11 @@ import { GQLCreateCommentInput, GQLCreateCommentReactionInput, GQLCreateCommentReplyInput, + GQLCreateIllegalContentInput, GQLEditCommentInput, GQLFeatureCommentInput, GQLMarkCommentsAsSeenInput, + GQLNOTIFICATION_TYPE, GQLRemoveCommentDontAgreeInput, GQLRemoveCommentReactionInput, GQLTAG, @@ -54,7 +57,9 @@ export const Comments = (ctx: GraphContext) => ({ ctx.wordList, ctx.cache, ctx.config, + ctx.i18n, ctx.broker, + ctx.notifications, ctx.tenant, ctx.user!, { @@ -84,6 +89,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.wordList, ctx.cache, ctx.config, + ctx.i18n, ctx.broker, ctx.tenant, ctx.user!, @@ -111,6 +117,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, + ctx.i18n, ctx.cache, ctx.broker, ctx.tenant, @@ -129,6 +136,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, + ctx.i18n, ctx.cache, ctx.broker, ctx.tenant, @@ -138,6 +146,26 @@ export const Comments = (ctx: GraphContext) => ({ commentRevisionID, } ), + createIllegalContent: async ({ + commentID, + commentRevisionID, + }: GQLCreateIllegalContentInput) => + createIllegalContent( + ctx.mongo, + ctx.redis, + ctx.config, + ctx.i18n, + ctx.cache.commentActions, + ctx.broker, + ctx.tenant, + ctx.user!, + await ctx.loaders.Comments.comment.load(commentID), + { + commentID, + commentRevisionID, + }, + ctx.now + ), createDontAgree: ({ commentID, commentRevisionID, @@ -147,6 +175,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, + ctx.i18n, ctx.cache.commentActions, ctx.broker, ctx.tenant, @@ -170,6 +199,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, + ctx.i18n, ctx.cache, ctx.broker, ctx.tenant, @@ -189,6 +219,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, + ctx.i18n, ctx.cache.commentActions, ctx.broker, ctx.tenant, @@ -229,7 +260,9 @@ export const Comments = (ctx: GraphContext) => ({ ctx.redis, ctx.cache, ctx.config, + ctx.i18n, ctx.broker, + ctx.notifications, ctx.tenant, commentID, commentRevisionID, @@ -254,6 +287,12 @@ export const Comments = (ctx: GraphContext) => ({ // Publish that the comment was featured. await publishCommentFeatured(ctx.broker, comment); + await ctx.notifications.create(ctx.tenant.id, ctx.tenant.locale, { + targetUserID: comment.authorID!, + comment, + type: GQLNOTIFICATION_TYPE.COMMENT_FEATURED, + }); + // Return it to the next step. return comment; }, diff --git a/server/src/core/server/graph/mutators/DSAReports.ts b/server/src/core/server/graph/mutators/DSAReports.ts new file mode 100644 index 0000000000..06a9a3011f --- /dev/null +++ b/server/src/core/server/graph/mutators/DSAReports.ts @@ -0,0 +1,96 @@ +import GraphContext from "coral-server/graph/context"; +import { createIllegalContent } from "coral-server/services/comments"; +import { + addDSAReportNote, + addDSAReportShare, + changeDSAReportStatus, + createDSAReport, + deleteDSAReportNote, + makeDSAReportDecision, +} from "coral-server/services/dsaReports/reports"; + +import { + GQLAddDSAReportNoteInput, + GQLAddDSAReportShareInput, + GQLChangeDSAReportStatusInput, + GQLCreateDSAReportInput, + GQLDeleteDSAReportNoteInput, + GQLMakeDSAReportDecisionInput, +} from "coral-server/graph/schema/__generated__/types"; + +export const DSAReports = (ctx: GraphContext) => ({ + createDSAReport: async ({ + commentID, + userID, + lawBrokenDescription, + additionalInformation, + submissionID, + commentRevisionID, + }: GQLCreateDSAReportInput) => { + const report = await createDSAReport(ctx.mongo, ctx.tenant, { + commentID, + userID, + lawBrokenDescription, + additionalInformation, + submissionID, + }); + + if (ctx.user) { + await createIllegalContent( + ctx.mongo, + ctx.redis, + ctx.config, + ctx.i18n, + ctx.cache.commentActions, + ctx.broker, + ctx.tenant, + ctx.user, + await ctx.loaders.Comments.comment.load(commentID), + { commentID, commentRevisionID, reportID: report.id }, + ctx.now + ); + } + }, + addDSAReportNote: ({ userID, body, reportID }: GQLAddDSAReportNoteInput) => + addDSAReportNote(ctx.mongo, ctx.tenant, { userID, body, reportID }), + addDSAReportShare: ({ userID, reportID }: GQLAddDSAReportShareInput) => + addDSAReportShare(ctx.mongo, ctx.tenant, { userID, reportID }), + deleteDSAReportNote: ({ id, reportID }: GQLDeleteDSAReportNoteInput) => + deleteDSAReportNote(ctx.mongo, ctx.tenant, { id, reportID }), + changeDSAReportStatus: ({ + userID, + status, + reportID, + }: GQLChangeDSAReportStatusInput) => + changeDSAReportStatus(ctx.mongo, ctx.tenant, { userID, status, reportID }), + makeDSAReportDecision: async ({ + userID, + legality, + legalGrounds, + detailedExplanation, + reportID, + commentID, + commentRevisionID, + }: GQLMakeDSAReportDecisionInput) => + makeDSAReportDecision( + ctx.mongo, + ctx.redis, + ctx.cache, + ctx.config, + ctx.i18n, + ctx.broker, + ctx.notifications, + ctx.tenant, + await ctx.loaders.Comments.comment.load(commentID), + { + userID, + legality, + legalGrounds, + detailedExplanation, + reportID, + commentID, + commentRevisionID, + }, + ctx.req + ), +}); diff --git a/server/src/core/server/graph/mutators/Users.ts b/server/src/core/server/graph/mutators/Users.ts index 89c9f54309..73b3d65428 100644 --- a/server/src/core/server/graph/mutators/Users.ts +++ b/server/src/core/server/graph/mutators/Users.ts @@ -185,9 +185,11 @@ export const Users = (ctx: GraphContext) => ({ ctx.mongo, ctx.redis, ctx.config, + ctx.i18n, input.userID, ctx.tenant.id, - ctx.now + ctx.now, + ctx.tenant.dsa?.enabled ); }, cancelAccountDeletion: async ( @@ -344,6 +346,7 @@ export const Users = (ctx: GraphContext) => ({ ctx.user!, userID, message, + ctx.i18n, rejectExistingComments, siteIDs, ctx.now @@ -363,6 +366,7 @@ export const Users = (ctx: GraphContext) => ({ ctx.mailerQueue, ctx.rejectorQueue, ctx.tenant, + ctx.i18n, ctx.user!, userID, message, diff --git a/server/src/core/server/graph/mutators/index.ts b/server/src/core/server/graph/mutators/index.ts index 311f57a42c..9e6174b6d4 100644 --- a/server/src/core/server/graph/mutators/index.ts +++ b/server/src/core/server/graph/mutators/index.ts @@ -2,6 +2,7 @@ import GraphContext from "coral-server/graph/context"; import { Actions } from "./Actions"; import { Comments } from "./Comments"; +import { DSAReports } from "./DSAReports"; import { Redis } from "./Redis"; import { Settings } from "./Settings"; import { Sites } from "./Sites"; @@ -11,6 +12,7 @@ import { Users } from "./Users"; const root = (ctx: GraphContext) => ({ Actions: Actions(ctx), Comments: Comments(ctx), + DSAReports: DSAReports(ctx), Settings: Settings(ctx), Stories: Stories(ctx), Users: Users(ctx), diff --git a/server/src/core/server/graph/plugins/helpers.ts b/server/src/core/server/graph/plugins/helpers.ts index e5f6c41543..94465e66f2 100644 --- a/server/src/core/server/graph/plugins/helpers.ts +++ b/server/src/core/server/graph/plugins/helpers.ts @@ -101,7 +101,7 @@ function hoistCoralErrorExtensions( } // Get the translation bundle. - const bundle = ctx.i18n.getBundle(ctx.lang); + const bundle = ctx.i18n.getBundle(ctx.tenant ? ctx.tenant.locale : ctx.lang); // Translate the extensions. const extensions = originalError.serializeExtensions(bundle, ctx.id); diff --git a/server/src/core/server/graph/resolvers/Comment.ts b/server/src/core/server/graph/resolvers/Comment.ts index c9b68e0d5a..e305d1d1c6 100644 --- a/server/src/core/server/graph/resolvers/Comment.ts +++ b/server/src/core/server/graph/resolvers/Comment.ts @@ -32,6 +32,7 @@ import { } from "coral-server/graph/schema/__generated__/types"; import GraphContext from "../context"; +import { isQA, isRatingsAndReviews } from "../loaders/Comments"; import { setCacheHint } from "../setCacheHint"; export const maybeLoadOnlyID = async ( @@ -198,14 +199,34 @@ export const Comment: GQLCommentTypeResolver = { commentID: id, }, }), - viewerActionPresence: (c, input, ctx, info) => { + illegalContent: ({ id }, { first, after }, ctx) => + ctx.loaders.CommentActions.connection({ + first: defaultTo(first, 10), + after, + orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC, + filter: { + actionType: ACTION_TYPE.ILLEGAL, + commentID: id, + }, + }), + viewerActionPresence: async (c, input, ctx, info) => { if (!ctx.user) { return null; } setCacheHint(info, { scope: CacheScope.Private }); - return ctx.loaders.Comments.retrieveMyActionPresence.load(c.id); + const story = await ctx.loaders.Stories.find.load({ id: c.storyID }); + if (!story) { + throw new StoryNotFoundError(c.storyID); + } + + return ctx.loaders.Comments.retrieveMyActionPresence.load({ + commentID: c.id, + isArchived: !!story.isArchived, + isRR: isRatingsAndReviews(ctx.tenant, story), + isQA: isQA(ctx.tenant, story), + }); }, parentCount: (c) => getDepth(c), diff --git a/server/src/core/server/graph/resolvers/DSAConfiguration.ts b/server/src/core/server/graph/resolvers/DSAConfiguration.ts new file mode 100644 index 0000000000..1edf2f6930 --- /dev/null +++ b/server/src/core/server/graph/resolvers/DSAConfiguration.ts @@ -0,0 +1,18 @@ +import * as settings from "coral-server/models/settings"; + +import { + GQLDSA_METHOD_OF_REDRESS, + GQLDSAConfigurationTypeResolver, +} from "coral-server/graph/schema/__generated__/types"; + +export const DSAConfiguration: GQLDSAConfigurationTypeResolver = + { + enabled: (config, args, { tenant }) => + tenant.dsa && tenant.dsa.enabled ? tenant.dsa.enabled : false, + methodOfRedress: (config, args, { tenant }) => + tenant.dsa?.methodOfRedress ?? { + method: GQLDSA_METHOD_OF_REDRESS.NONE, + email: "", + url: "", + }, + }; diff --git a/server/src/core/server/graph/resolvers/DSAMethodOfRedressConfiguration.ts b/server/src/core/server/graph/resolvers/DSAMethodOfRedressConfiguration.ts new file mode 100644 index 0000000000..a56cf99c8b --- /dev/null +++ b/server/src/core/server/graph/resolvers/DSAMethodOfRedressConfiguration.ts @@ -0,0 +1,13 @@ +import { + GQLDSA_METHOD_OF_REDRESS, + GQLDSAMethodOfRedressConfigurationTypeResolver, +} from "coral-server/graph/schema/__generated__/types"; + +export const DSAMethodOfRedressConfiguration: GQLDSAMethodOfRedressConfigurationTypeResolver = + { + method: (config, args, { tenant }) => + tenant.dsa?.methodOfRedress?.method ?? GQLDSA_METHOD_OF_REDRESS.NONE, + email: (config, args, { tenant }) => + tenant.dsa?.methodOfRedress?.email ?? "", + url: (config, args, { tenant }) => tenant.dsa?.methodOfRedress?.url ?? "", + }; diff --git a/server/src/core/server/graph/resolvers/DSAReport.ts b/server/src/core/server/graph/resolvers/DSAReport.ts new file mode 100644 index 0000000000..17ac248b6b --- /dev/null +++ b/server/src/core/server/graph/resolvers/DSAReport.ts @@ -0,0 +1,48 @@ +import { defaultTo } from "lodash"; + +import * as dsaReport from "coral-server/models/dsaReport"; + +import { + GQLDSAReportTypeResolver, + GQLREPORT_SORT, +} from "coral-server/graph/schema/__generated__/types"; + +export const DSAReport: GQLDSAReportTypeResolver = { + reporter: (report, args, ctx) => { + if (report.userID) { + return ctx.loaders.Users.user.load(report.userID); + } + + return null; + }, + comment: ({ commentID }, args, ctx) => { + if (commentID) { + return ctx.loaders.Comments.comment.load(commentID); + } + + return null; + }, + history: ({ history }, args, ctx) => { + const consolidatedHistory = history.map((h) => { + const createdUser = h.createdBy + ? ctx.loaders.Users.user.load(h.createdBy) + : null; + return { ...h, createdBy: createdUser }; + }); + return consolidatedHistory ?? []; + }, + lastUpdated: ({ history }, args, ctx) => { + if (history.length > 0) { + return history[history.length - 1].createdAt; + } else { + return null; + } + }, + relatedReports: ({ submissionID, id }, { first, after, orderBy }, ctx) => { + return ctx.loaders.DSAReports.relatedReports(submissionID, id, { + first: defaultTo(first, 10), + after, + orderBy: defaultTo(orderBy, GQLREPORT_SORT.CREATED_AT_DESC), + }); + }, +}; diff --git a/server/src/core/server/graph/resolvers/Mutation.ts b/server/src/core/server/graph/resolvers/Mutation.ts index a12c338897..26e23ffb2d 100644 --- a/server/src/core/server/graph/resolvers/Mutation.ts +++ b/server/src/core/server/graph/resolvers/Mutation.ts @@ -67,6 +67,30 @@ export const Mutation: Required> = { comment: await ctx.mutators.Comments.createFlag(input), clientMutationId: input.clientMutationId, }), + createDSAReport: async (source, { input }, ctx) => ({ + dsaReport: await ctx.mutators.DSAReports.createDSAReport(input), + clientMutationId: input.clientMutationId, + }), + addDSAReportNote: async (source, { input }, ctx) => ({ + dsaReport: await ctx.mutators.DSAReports.addDSAReportNote(input), + clientMutationId: input.clientMutationId, + }), + deleteDSAReportNote: async (source, { input }, ctx) => ({ + dsaReport: await ctx.mutators.DSAReports.deleteDSAReportNote(input), + clientMutationId: input.clientMutationId, + }), + changeDSAReportStatus: async (source, { input }, ctx) => ({ + dsaReport: await ctx.mutators.DSAReports.changeDSAReportStatus(input), + clientMutationId: input.clientMutationId, + }), + addDSAReportShare: async (source, { input }, ctx) => ({ + dsaReport: await ctx.mutators.DSAReports.addDSAReportShare(input), + clientMutationId: input.clientMutationId, + }), + makeDSAReportDecision: async (source, { input }, ctx) => ({ + dsaReport: await ctx.mutators.DSAReports.makeDSAReportDecision(input), + clientMutationId: input.clientMutationId, + }), featureComment: async ( source, { input: { clientMutationId, ...input } }, diff --git a/server/src/core/server/graph/resolvers/Notification.ts b/server/src/core/server/graph/resolvers/Notification.ts new file mode 100644 index 0000000000..cd765f1766 --- /dev/null +++ b/server/src/core/server/graph/resolvers/Notification.ts @@ -0,0 +1,33 @@ +import { Notification } from "coral-server/models/notifications/notification"; + +import { + GQLNOTIFICATION_TYPE, + GQLNotificationTypeResolver, +} from "coral-server/graph/schema/__generated__/types"; + +export const NotificationResolver: Required< + GQLNotificationTypeResolver +> = { + id: ({ id }) => id, + ownerID: ({ ownerID }) => ownerID, + type: ({ type }) => (type ? type : GQLNOTIFICATION_TYPE.UNKNOWN), + createdAt: ({ createdAt }) => createdAt, + comment: async ({ commentID }, input, ctx) => { + if (!commentID) { + return null; + } + + return await ctx.loaders.Comments.comment.load(commentID); + }, + commentStatus: ({ commentStatus }) => commentStatus, + dsaReport: async ({ reportID }, input, ctx) => { + if (!reportID) { + return null; + } + + return await ctx.loaders.DSAReports.dsaReport.load({ id: reportID }); + }, + decisionDetails: ({ decisionDetails }) => decisionDetails, + rejectionReason: ({ rejectionReason }) => rejectionReason, + customReason: ({ customReason }) => customReason, +}; diff --git a/server/src/core/server/graph/resolvers/NotificationDSAReportDetails.ts b/server/src/core/server/graph/resolvers/NotificationDSAReportDetails.ts new file mode 100644 index 0000000000..72dc623a8a --- /dev/null +++ b/server/src/core/server/graph/resolvers/NotificationDSAReportDetails.ts @@ -0,0 +1,28 @@ +import { DSAReport } from "coral-server/models/dsaReport/report"; + +import { GQLNotificationDSAReportDetailsTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export const NotificationDSAReportDetailsResolver: Required< + GQLNotificationDSAReportDetailsTypeResolver +> = { + id: ({ id }) => id, + createdAt: ({ createdAt }) => createdAt, + referenceID: ({ referenceID }) => referenceID, + comment: async ({ commentID }, input, ctx) => { + if (!commentID) { + return null; + } + + return await ctx.loaders.Comments.comment.load(commentID); + }, + user: async ({ userID }, input, ctx) => { + if (!userID) { + return null; + } + + return await ctx.loaders.Users.user.load(userID); + }, + lawBrokenDescription: ({ lawBrokenDescription }) => lawBrokenDescription, + additionalInformation: ({ additionalInformation }) => additionalInformation, + submissionID: ({ submissionID }) => submissionID, +}; diff --git a/server/src/core/server/graph/resolvers/Query.ts b/server/src/core/server/graph/resolvers/Query.ts index c8857f7aa7..c8ac767860 100644 --- a/server/src/core/server/graph/resolvers/Query.ts +++ b/server/src/core/server/graph/resolvers/Query.ts @@ -1,6 +1,8 @@ import { defaultTo } from "lodash"; +import { UserNotFoundError } from "coral-server/errors"; import { ACTION_TYPE } from "coral-server/models/action/comment"; +import { markLastSeenNotification } from "coral-server/models/notifications/notification"; import { getEmailDomain, getExternalModerationPhase, @@ -50,6 +52,9 @@ export const Query: Required> = { ctx.tenant.emailDomainModeration ? getEmailDomain(ctx.tenant.emailDomainModeration, id) : null, + dsaReports: (source, args, ctx) => ctx.loaders.DSAReports.connection(args), + dsaReport: (source, { id }, ctx) => + id ? ctx.loaders.DSAReports.dsaReport.load({ id }) : null, flags: (source, { first, after, orderBy, storyID, siteID, section }, ctx) => ctx.loaders.CommentActions.forFilter({ first: defaultTo(first, 10), @@ -71,4 +76,25 @@ export const Query: Required> = { }, }, }), + notifications: async (source, { ownerID, first, after }, ctx) => { + const user = await ctx.loaders.Users.user.load(ownerID); + if (!user) { + throw new UserNotFoundError(ownerID); + } + + const connection = await ctx.loaders.Notifications.connection({ + ownerID, + first: defaultTo(first, 10), + after, + }); + + await markLastSeenNotification( + ctx.tenant.id, + ctx.mongo, + user, + connection.nodes.map((n) => n.createdAt) + ); + + return connection; + }, }; diff --git a/server/src/core/server/graph/resolvers/Settings.ts b/server/src/core/server/graph/resolvers/Settings.ts index 4c922a80ca..1e81475f50 100644 --- a/server/src/core/server/graph/resolvers/Settings.ts +++ b/server/src/core/server/graph/resolvers/Settings.ts @@ -1,4 +1,7 @@ -import { defaultRTEConfiguration } from "coral-server/models/settings"; +import { + defaultDSAConfiguration, + defaultRTEConfiguration, +} from "coral-server/models/settings"; import validFeatureFlagsFilter from "coral-server/models/settings/validFeatureFlagsFilter"; import { areRepliesFlattened, @@ -54,10 +57,15 @@ export const Settings: GQLSettingsTypeResolver = { return deprecated; }, embeddedComments: ( - { embeddedComments = { allowReplies: true } }, + { embeddedComments = { allowReplies: true, oEmbedAllowedOrigins: [] } }, args, ctx - ) => embeddedComments, + ) => { + return { + allowReplies: embeddedComments.allowReplies ?? true, + oEmbedAllowedOrigins: embeddedComments.oEmbedAllowedOrigins ?? [], + }; + }, flairBadges: ({ flairBadges = { flairBadgesEnabled: false, badges: [] }, }) => { @@ -69,4 +77,5 @@ export const Settings: GQLSettingsTypeResolver = { } return flairBadges; }, + dsa: ({ dsa = defaultDSAConfiguration }) => dsa, }; diff --git a/server/src/core/server/graph/resolvers/Subscription/commentEnteredModerationQueue.ts b/server/src/core/server/graph/resolvers/Subscription/commentEnteredModerationQueue.ts index e40e7c061b..f28d0c8fa0 100644 --- a/server/src/core/server/graph/resolvers/Subscription/commentEnteredModerationQueue.ts +++ b/server/src/core/server/graph/resolvers/Subscription/commentEnteredModerationQueue.ts @@ -17,6 +17,7 @@ export interface CommentEnteredModerationQueueInput extends SubscriptionPayload { queue: GQLMODERATION_QUEUE; commentID: string; + status?: string; storyID: string; siteID: string; section?: string; diff --git a/server/src/core/server/graph/resolvers/Subscription/commentLeftModerationQueue.ts b/server/src/core/server/graph/resolvers/Subscription/commentLeftModerationQueue.ts index 894fdcfc7d..479ff998ec 100644 --- a/server/src/core/server/graph/resolvers/Subscription/commentLeftModerationQueue.ts +++ b/server/src/core/server/graph/resolvers/Subscription/commentLeftModerationQueue.ts @@ -16,6 +16,7 @@ import { export interface CommentLeftModerationQueueInput extends SubscriptionPayload { queue: GQLMODERATION_QUEUE; commentID: string; + status?: string; storyID: string; siteID: string; section?: string; diff --git a/server/src/core/server/graph/resolvers/User.ts b/server/src/core/server/graph/resolvers/User.ts index f1ec3c7d22..dcf1a1e1fc 100644 --- a/server/src/core/server/graph/resolvers/User.ts +++ b/server/src/core/server/graph/resolvers/User.ts @@ -89,4 +89,17 @@ export const User: GQLUserTypeResolver = { return ctx.loaders.Stories.story.loadMany(results.map(({ _id }) => _id)); }, mediaSettings: ({ mediaSettings = {} }) => mediaSettings, + hasNewNotifications: ({ lastSeenNotificationDate }, input, ctx) => { + if (!ctx.user) { + return false; + } + + return ctx.loaders.Notifications.hasNewNotifications( + ctx.user.id, + ctx.user.lastSeenNotificationDate ?? new Date(0) + ); + }, + lastSeenNotificationDate: ({ lastSeenNotificationDate }) => { + return lastSeenNotificationDate ?? new Date(0); + }, }; diff --git a/server/src/core/server/graph/resolvers/index.ts b/server/src/core/server/graph/resolvers/index.ts index 3d25555cce..03f237aa3f 100644 --- a/server/src/core/server/graph/resolvers/index.ts +++ b/server/src/core/server/graph/resolvers/index.ts @@ -25,6 +25,9 @@ import { CommentReplyCreatedPayload } from "./CommentReplyCreatedPayload"; import { CommentRevision } from "./CommentRevision"; import { CommentStatusUpdatedPayload } from "./CommentStatusUpdatedPayload"; import { DisableCommenting } from "./DisableCommenting"; +import { DSAConfiguration } from "./DSAConfiguration"; +import { DSAMethodOfRedressConfiguration } from "./DSAMethodOfRedressConfiguration"; +import { DSAReport } from "./DSAReport"; import { EditInfo } from "./EditInfo"; import { EmailDomain } from "./EmailDomain"; import { ExternalMediaConfiguration } from "./ExternalMediaConfiguration"; @@ -45,6 +48,8 @@ import { ModMessageStatus } from "./ModMessageStatus"; import { ModMessageStatusHistory } from "./ModMessageStatusHistory"; import { Mutation } from "./Mutation"; import { NewCommentersConfiguration } from "./NewCommentersConfiguration"; +import { NotificationResolver as Notification } from "./Notification"; +import { NotificationDSAReportDetailsResolver as NotificationDSAReportDetails } from "./NotificationDSAReportDetails"; import { OIDCAuthIntegration } from "./OIDCAuthIntegration"; import { PremodStatus } from "./PremodStatus"; import { PremodStatusHistory } from "./PremodStatusHistory"; @@ -105,6 +110,7 @@ const Resolvers: GQLResolver = { CommentStatusUpdatedPayload, Cursor, DisableCommenting, + DSAReport, EditInfo, EmailDomain, ExternalMediaConfiguration, @@ -165,6 +171,10 @@ const Resolvers: GQLResolver = { YouTubeMediaConfiguration, LocalAuthIntegration, AuthenticationTargetFilter, + Notification, + NotificationDSAReportDetails, + DSAConfiguration, + DSAMethodOfRedressConfiguration, }; export default Resolvers; diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 23cd467c9a..58eb8f4139 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -258,6 +258,13 @@ type DontAgreeActionCounts { total: Int! } +type IllegalActionCounts { + """ + total is the total number of illegal actions against a given item. + """ + total: Int! +} + type Reacter { username: String userID: String @@ -359,6 +366,12 @@ type Flag { createdAt is when this flag was created. """ createdAt: Time! + + """ + reportID is the id of the DSAReport that was created when the comment was flagged + as potentially containing illegal content. Only exists for illegal content flag type. + """ + reportID: String } type FlagEdge { @@ -427,6 +440,11 @@ type ActionCounts { restricted to administrators and moderators. """ flag: FlagActionCounts! @auth(roles: [ADMIN, MODERATOR]) + + """ + illegal returns the counts for the illegal content action on on item. + """ + illegal: IllegalActionCounts! } """ @@ -449,6 +467,11 @@ type ActionPresence { flag is true when a flag action was left on an item. """ flag: Boolean! + + """ + flag is true when an illegal content actions was left on an item. + """ + illegal: Boolean! } ################################################################################ @@ -1673,6 +1696,10 @@ EmbeddedCommentsConfiguration specifies the configuration for embedded comments. """ type EmbeddedCommentsConfiguration { allowReplies: Boolean + """ + oEmbedAllowedOrigins are the allowed origins for oEmbed API calls. + """ + oEmbedAllowedOrigins: [String!]! @auth(roles: [ADMIN, MODERATOR]) } """ @@ -1710,7 +1737,6 @@ type BadgeConfiguration { FlairBadgeConfiguration specifies the configuration for flair badges, including whether they are enabled and any configured image urls. """ - type FlairBadge { name: String! url: String! @@ -1734,6 +1760,7 @@ enum WEBHOOK_EVENT_NAME { STORY_CREATED COMMENT_CREATED COMMENT_REPLY_CREATED + COMMENT_LEFT_MODERATION_QUEUE } type WebhookEndpoint { @@ -1928,6 +1955,44 @@ type RTEConfiguration { sarcasm: Boolean! } +enum DSA_METHOD_OF_REDRESS { + NONE + EMAIL + URL +} + +type DSAMethodOfRedressConfiguration { + """ + method defines the type of redress that is available to the + users about DSA decisions. + """ + method: DSA_METHOD_OF_REDRESS! + + """ + email is the email used when method is set to EMAIL. + """ + email: String + + """ + url is the url that is used when method is set to URL. + """ + url: String +} + +type DSAConfiguration { + """ + enabled when true turns on the European Union DSA compliance + features for commenting, reporting, and moderation flows. + """ + enabled: Boolean! + + """ + methodOfRedress lets users know if and how they can appeal a + moderation decision + """ + methodOfRedress: DSAMethodOfRedressConfiguration! +} + """ Settings stores the global settings for a given Tenant. """ @@ -2222,6 +2287,12 @@ type Settings @cacheControl(maxAge: 30) { they are enabled and any configured image urls """ flairBadges: FlairBadgeConfiguration + + """ + dsa specifies the configuration for the European Union's DSA compliance + features and whether they are enabled. + """ + dsa: DSAConfiguration! } ################################################################################ @@ -2657,6 +2728,13 @@ type UserStatus { ) } +enum DSAREPORT_STATUS_FILTER { + AWAITING_REVIEW + UNDER_REVIEW + COMPLETED + VOID +} + enum USER_STATUS_FILTER { """ ACTIVE is used when a User is not suspended or banned. @@ -3140,6 +3218,27 @@ type User { bio is a user-defined biography. """ bio: String + + """ + hasNewNotifications returns true if the user has received new notifications + since they last viewed their notifications tab. + """ + hasNewNotifications: Boolean! + @auth( + userIDField: "id" + roles: [ADMIN, MODERATOR] + permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED] + ) + + """ + lastSeenNotificationDate is the date of the last notification the user viewed. + """ + lastSeenNotificationDate: Time + @auth( + userIDField: "id" + roles: [ADMIN, MODERATOR] + permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED] + ) } """ @@ -3212,6 +3311,41 @@ type SitesConnection { pageInfo: PageInfo! } +""" +DSAReportEdge represents a unique DSAReport in a DSAReportConnection. +""" +type DSAReportEdge { + """ + node is the DSAReport for this edge. + """ + node: DSAReport! + + """ + cursor is used in pagination. + """ + cursor: Cursor! +} + +""" +DSAReportsConnection represents a subset of a DSA reports list. +""" +type DSAReportsConnection { + """ + edges are a subset of DSAReportEdge's. + """ + edges: [DSAReportEdge!]! + + """ + nodes is a list of DSAReports. + """ + nodes: [DSAReport!]! + + """ + pageInfo is information to aid in pagination. + """ + pageInfo: PageInfo! +} + ################################################################################ ## Comment ################################################################################ @@ -3260,6 +3394,85 @@ enum COMMENT_STATUS { SYSTEM_WITHHELD } +enum REJECTION_REASON_CODE { + """ + OFFENSIVE represents a rejection of a comment for being offensive. + """ + OFFENSIVE + + """ + ABUSIVE represents a rejection of a comment for being abusive. + """ + ABUSIVE + + """ + SPAM represents a rejection of a comment for being spam. + """ + SPAM + + """ + BANNED_WORD represents a rejection of a comment for containing a banned word. + """ + BANNED_WORD + + """ + AD represents a rejection of a comment for being and ad. + """ + AD + + """ + ILLEGAL_CONTENT represents a rejection of a comment for containing illegal content. + """ + ILLEGAL_CONTENT + + """ + HARASSMENT_BULLYING represents a rejection of a comment for being harassment or bullying. + """ + HARASSMENT_BULLYING + + """ + MISINFORMATION represents a rejection of a comment for being misinformation. + """ + MISINFORMATION + + """ + HATE_SPEECH represents a rejection of a comment for being hate speech. + """ + HATE_SPEECH + + """ + IRRELEVANT_CONTENT represents a rejection of a comment for being irrelevant to the conversation. + """ + IRRELEVANT_CONTENT + + """ + OTHER is reserved for reasons that arent adequately described by the other options. + """ + OTHER +} + +type RejectionReason { + """ + code is the reason that the comment was rejected + """ + code: REJECTION_REASON_CODE! + + """ + legalGrounds is the specific laws broken as described by the reporter + """ + legalGrounds: String + + """ + detailedExplanation is any additional information the user wishes to provide. + """ + detailedExplanation: String + + """ + customReason is a reason provided for rejection when the Other rejection code is selected. + """ + customReason: String +} + type CommentModerationAction { id: ID! @@ -3284,10 +3497,20 @@ type CommentModerationAction { """ moderator: User + """ + reason is the reason the comment was rejected, if it was rejected + """ + rejectionReason: RejectionReason + """ createdAt is the time that the CommentModerationAction was created. """ createdAt: Time! + + """ + customReason is a reason provided for rejection when the Other rejection code is selected. + """ + customReason: String } type CommentModerationActionEdge { @@ -3319,6 +3542,154 @@ type CommentModerationActionConnection { pageInfo: PageInfo! } +""" +DSAReportStatus keeps track of where a DSAReport is in the process of being reviewed +""" +enum DSAReportStatus { + AWAITING_REVIEW + UNDER_REVIEW + COMPLETED + """ + VOID is set for a DSAReport if its reported comment/user are deleted while the report is in review + """ + VOID +} + +""" +DSAReportDecision keeps track of whether a DSAReport was determined illegal or not +""" +enum DSAReportDecisionLegality { + LEGAL + ILLEGAL +} + +enum DSAReportHistoryType { + STATUS_CHANGED + NOTE + DECISION_MADE + SHARE +} + +type DSAReportDecision { + legality: DSAReportDecisionLegality! + legalGrounds: String + detailedExplanation: String +} + +type DSAReportHistoryItem { + id: ID! + + """ + type is the kind of DSA report history item this is + """ + type: DSAReportHistoryType + + """ + createdAt is when this report history item was created + """ + createdAt: Time! + + """ + createdBy is the user who added this report history item + """ + createdBy: User + + """ + decision is included if the report history item is making a decision + """ + decision: DSAReportDecision + + """ + note is included as an explanation for a report history item decision + """ + note: String + + """ + status is the new status if the report history item type changes the status + """ + status: DSAReportStatus + + """ + body is the text added if the report history item decision is a note added + """ + body: String +} + +type DSAReport { + id: ID! + + """ + referenceID is a human-friendly ID to keep track of the DSAReport + """ + referenceID: String! + + """ + submissionID keeps track of comments that were submitted together in one form + """ + submissionID: String! + + """ + user who submitted the DSAReport + """ + reporter: User + + """ + comment reported as containing illegal content + """ + comment: Comment + + """ + createdAt is when the DSAReport was created + """ + createdAt: Time! + + """ + status of DSAReport + """ + status: DSAReportStatus + + """ + lawBrokenDescription is the text entered by the submitting user to describe + which law is broken by the reported comment + """ + lawBrokenDescription: String! + + """ + additionalInformation is more explanation entereted by the submitting user to + describe how the comment breaks the law + """ + additionalInformation: String! + + """ + decision is the determination of whether the reported comment is illegal or not, as well as + the legal grounds for the decision and additional detailed explanation of decision + """ + decision: DSAReportDecision + + """ + history includes report history items such as notes added to a report, when a report's + status is changed, and if a decision is made regarding whether the related comment contains + illegal content or not + """ + history: [DSAReportHistoryItem] + + """ + lastUpdated is when the DSAReport last had an action created and added to its history, + such as a note added, status changed, or decision made + """ + lastUpdated: Time + + """ + relatedReports are DSAReports that share a submissionID and were submitted at the same time + in one illegal content report + """ + relatedReports( + first: Int = 10 @constraint(max: 50) + orderBy: REPORT_SORT = CREATED_AT_DESC + after: Cursor + ): DSAReportsConnection! +} + type CommentRevisionPerspectiveMetadata { """ score is the value detected from the perspective API. This is returned as the @@ -3819,6 +4190,14 @@ type Comment @cacheControl(maxAge: 5) { flags(first: Int = 10 @constraint(max: 50), after: Cursor): FlagsConnection! @auth(roles: [ADMIN, MODERATOR]) + """ + illegalContent is the illegal content flags left by users. + """ + illegalContent( + first: Int = 10 @constraint(max: 50) + after: Cursor + ): FlagsConnection! @auth(roles: [ADMIN, MODERATOR]) + """ viewerActionPresence stores the presence information for all the actions left by the current User on this Comment. @@ -3996,6 +4375,11 @@ enum COMMENT_SORT { REACTION_DESC } +enum REPORT_SORT { + CREATED_AT_DESC + CREATED_AT_ASC +} + """ StoryMetadata stores all the metadata that is scraped using the scraping tools inside Coral. Coral utilizes [metascraper](https://metascraper.js.org/) which uses @@ -4378,6 +4762,171 @@ type Queues { unarchiver: Queue! } +################################################################################ +## Notifications +################################################################################ + +enum NOTIFICATION_TYPE { + UNKNOWN + COMMENT_FEATURED + COMMENT_APPROVED + COMMENT_REJECTED + ILLEGAL_REJECTED + DSA_REPORT_DECISION_MADE +} + +type Notification { + """ + id is the uuid identifier for the notification. + """ + id: ID! + + """ + ownerID is the string identifier for who this notification is directed to. + """ + ownerID: ID! + + """ + type defines the template type for this notification used to generate + it. Useful for sorting and filtering categorized notifications. + """ + type: NOTIFICATION_TYPE + + """ + createdAt is when this notification was created, used for sorting. + """ + createdAt: Time! + + """ + comment is the optional comment that is linked to this notification. + """ + comment: Comment + + """ + commentStatus is the optional status of the comment when the notification + was created. This allows for the context of the state of the comment to be + persisted even if the comment reference undergoes multiple moderation actions + since the notification was created. + """ + commentStatus: COMMENT_STATUS + + """ + rejectionReason is an optional field that defines why a comment was rejected. + """ + rejectionReason: REJECTION_REASON_CODE + + """ + decisionDetails is an optional field that contains further details pertaining + to DSA or moderation decisions. + """ + decisionDetails: NotificationDecisionDetails + + """ + customReason is a reason provided for rejection when the Other rejection code is selected. + """ + customReason: String + + """ + dsaReport is the details of the DSA Report related to the notification. + This is usually in reference to the comment that is also related to + the notification. + """ + dsaReport: NotificationDSAReportDetails +} + +type NotificationEdge { + """ + node is the Flag for this edge. + """ + node: Notification! + + """ + cursor is used in pagination. + """ + cursor: Cursor! +} + +type NotificationsConnection { + """ + edges are a subset of FlagEdge's. + """ + edges: [NotificationEdge!]! + + """ + nodes is a list of Flags. + """ + nodes: [Notification!]! + + """ + pageInfo is information to aid in pagination. + """ + pageInfo: PageInfo! +} + +type NotificationDSAReportDetails { + """ + id is the primary identifier of the DSA report. + """ + id: ID! + + """ + createdAt is the date when this report was created + """ + createdAt: Time! + + """ + referenceID is the friendly identifier that is human readable + for the DSA report. + """ + referenceID: String! + + """ + comment is the comment associated with the DSA report. + """ + comment: Comment + + """ + user is the target user (of the comment) the DSA report pertains to. + """ + user: User + + """ + lawBrokenDescription describes the law that was allegedly broken in the + DSA report. + """ + lawBrokenDescription: String @auth(roles: [ADMIN, MODERATOR]) + + """ + additionalInformation is any further relevant details added by the user + who filed the DSA report. + """ + additionalInformation: String @auth(roles: [ADMIN, MODERATOR]) + + """ + submissionID is the linking id that connects this DSA report to other reports + that may have been submitted at the same time by the same reporting user as + a collection of co-related DSA reports. + """ + submissionID: ID @auth(roles: [ADMIN, MODERATOR]) +} + +type NotificationDecisionDetails { + """ + legality defines whether the rejection reason is of legal or illegal state. + """ + legality: DSAReportDecisionLegality + + """ + grounds is the legal grounds for which the rejection may have been made. + """ + grounds: String + + """ + explanation gives further context to the reasons for rejection. + """ + explanation: String +} + ################################################################################ ## Query ################################################################################ @@ -4541,9 +5090,34 @@ type Query { ): FlagsConnection! @auth(roles: [ADMIN, MODERATOR]) """ - emailDomain will return a specific emailDomain configuration if it exists. + emailDomain will return a specific emailDomain configuration if it exists. + """ + emailDomain(id: ID!): EmailDomain @auth(roles: [ADMIN]) + + """ + dsaReports are the list of dsaReports + """ + dsaReports( + first: Int = 10 @constraint(max: 50) + after: Cursor + orderBy: REPORT_SORT = CREATED_AT_DESC + status: [DSAREPORT_STATUS_FILTER!] + query: String + ): DSAReportsConnection! @auth(roles: [ADMIN, MODERATOR]) + + """ + dsaReport returns a specific dsaReport. + """ + dsaReport(id: ID!): DSAReport + + """ + notifications will return a list of notifications for an ownerID. """ - emailDomain(id: ID!): EmailDomain @auth(roles: [ADMIN]) + notifications( + ownerID: ID! + first: Int = 10 @constraint(max: 50) + after: Cursor + ): NotificationsConnection! } ################################################################################ @@ -5568,6 +6142,10 @@ EmbeddedCommentsConfigurationInput specifies the configuration for comment embed """ input EmbeddedCommentsConfigurationInput { allowReplies: Boolean + """ + oEmbedAllowedOrigins are the allowed origins for oEmbed API calls. + """ + oEmbedAllowedOrigins: [String!] } """ @@ -5598,6 +6176,46 @@ input FlairBadgeConfigurationInput { flairBadgeURLs: [String!] } +""" +DSAMethodOfRedressConfigurationInput specifies the methods of redress and +their configuration values for users disputing DSA reporting decisions. +""" +input DSAMethodOfRedressConfigurationInput { + """ + method defines the type of redress that is available to the + users about DSA decisions. + """ + method: DSA_METHOD_OF_REDRESS + + """ + email is the email used when method is set to EMAIL. + """ + email: String + + """ + url is the url that is used when method is set to URL. + """ + url: String +} + +""" +DSAConfigurationInput specifies the configuration for DSA European Union +moderation and reporting features. +""" +input DSAConfigurationInput { + """ + enabled when true turns on the European Union DSA compliance + features for commenting, reporting, and moderation flows. + """ + enabled: Boolean + + """ + methodOfRedress lets users know if and how they can appeal a + moderation decision + """ + methodOfRedress: DSAMethodOfRedressConfigurationInput +} + """ SettingsInput is the partial type of the Settings type for performing mutations. """ @@ -5802,6 +6420,12 @@ input SettingsInput { they are enabled and any configured image urls """ flairBadges: FlairBadgeConfigurationInput + + """ + dsa specifies the configuration for DSA European Union moderation and + reporting features. + """ + dsa: DSAConfigurationInput } """ @@ -5944,6 +6568,30 @@ type CreateCommentDontAgreePayload { clientMutationId: String! } +input CreateIllegalContentInput { + """ + commentID is the id of the comment reported for illegal content. + """ + commentID: ID! + + """ + commentRevisionID is the revision ID of the comment reported for illegal content. + """ + commentRevisionID: ID! +} + +type CreateIllegalContentPayload { + """ + comment is the Comment that the illegal content report was created on. + """ + comment: Comment + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## removeCommentDontAgree ################## @@ -6023,6 +6671,237 @@ type CreateCommentFlagPayload { clientMutationId: String! } +input AddDSAReportNoteInput { + """ + id of the user who added the note to the DSAReport + """ + userID: ID! + + """ + body is the text of the note added to the DSAReport + """ + body: String! + + """ + reportID is the id of the DSAReport to which the note is being added + """ + reportID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type AddDSAReportNotePayload { + """ + dsaReport is the DSAReport that the note was added to + """ + dsaReport: DSAReport + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +input AddDSAReportShareInput { + """ + id of the user who shared the DSAReport + """ + userID: ID! + + """ + reportID is the id of the DSAReport which was downloaded to share + """ + reportID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type AddDSAReportSharePayload { + """ + dsaReport is the DSAReport that was downloaded to share + """ + dsaReport: DSAReport + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +input DeleteDSAReportNoteInput { + """ + id of the note to be deleted from DSAReport history + """ + id: ID! + + """ + reportID is the id of the DSAReport from which the note is to be deleted + """ + reportID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type DeleteDSAReportNotePayload { + """ + dsaReport is the DSAReport from which the note was deleted + """ + dsaReport: DSAReport + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +input MakeDSAReportDecisionInput { + """ + id of the user who made a decision for the DSAReport + """ + userID: ID! + + """ + legality is the legality decision made for the DSAReport + """ + legality: DSAReportDecisionLegality! + + """ + legal grounds is an overview of the law broken as part of illegal content decision + """ + legalGrounds: String + + """ + detailedExplanation provides more information as part of illegal content decision + """ + detailedExplanation: String + + """ + commentID is the id of the comment about which the DSAReport legality decision is being made + """ + commentID: ID! + + """ + commentRevisionID is the revision id of the comment about which the DSAReport legality decision is being made + """ + commentRevisionID: ID! + + """ + reportID is the id of the DSAReport that is being decided on + """ + reportID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type MakeDSAReportDecisionPayload { + """ + dsaReport is the DSAReport that was decided on + """ + dsaReport: DSAReport + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +input ChangeDSAReportStatusInput { + """ + id of the user who changed the status of the DSAReport + """ + userID: ID! + + """ + status is the new status of the DSAReport + """ + status: DSAReportStatus! + + """ + reportID is the id of the DSAReport that is having its status changed + """ + reportID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type ChangeDSAReportStatusPayload { + """ + dsaReport is the DSAReport that had its status changed + """ + dsaReport: DSAReport + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +input CreateDSAReportInput { + """ + id of the reported comment + """ + commentID: ID! + + """ + id of the user who submitted the DSAReport + """ + userID: ID! + + """ + lawBrokenDescription is the text entered by the submitting user to describe + which law is broken by the reported comment + """ + lawBrokenDescription: String! + + """ + additionalInformation is more explanation entereted by the submitting user to + describe how the comment breaks the law + """ + additionalInformation: String! + + """ + submissionID keeps track of comments that were submitted together in one form + """ + submissionID: ID + + """ + commentRevisionID is the revision ID for the comment for which a DSA report is being submitted. + """ + commentRevisionID: String + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type CreateDSAReportPayload { + """ + dsaReport is the report that was newly created + """ + dsaReport: DSAReport + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## createStory ################## @@ -6473,6 +7352,28 @@ type ApproveCommentPayload { # rejectComment ################## +input RejectCommentReasonInput { + """ + code is the enumerated code for the reason the comment is being rejected. + """ + code: REJECTION_REASON_CODE! + + """ + legalGrounds is the specific laws broken as described by the reporter. + """ + legalGrounds: String + + """ + detailedExplanation is any additional information the user wishes to provide. + """ + detailedExplanation: String + + """ + customReason is a reason provided for rejection when the Other rejection code is selected. + """ + customReason: String +} + input RejectCommentInput { """ commentID is the ID of the Comment that was rejected. @@ -6484,6 +7385,11 @@ input RejectCommentInput { """ commentRevisionID: ID! + """ + reason is the reason the comment is being rejected if DSA features are enabled. + """ + reason: RejectCommentReasonInput + """ clientMutationId is required for Relay support. """ @@ -9740,6 +10646,42 @@ type Mutation { flushRedis flushes the redis instance. """ flushRedis(input: FlushRedisInput!): FlushRedisPayload! @auth(roles: [ADMIN]) + + """ + createDSAReport creates a DSAReport for the provided comment + """ + createDSAReport(input: CreateDSAReportInput!): CreateDSAReportPayload! + + """ + addDSAReportNote adds a note to the history for a DSAReport + """ + addDSAReportNote(input: AddDSAReportNoteInput!): AddDSAReportNotePayload! + + """ + addDSAReportShare adds that the report was downloaded and shared to the history for a DSAReport + """ + addDSAReportShare(input: AddDSAReportShareInput!): AddDSAReportSharePayload! + + """ + deleteDSAReportNote deletes a note from the history for a DSAReport + """ + deleteDSAReportNote( + input: DeleteDSAReportNoteInput! + ): DeleteDSAReportNotePayload! + + """ + changeDSAReportStatus changes the status of a DSAReport and adds the status change to the history for a DSAReport + """ + changeDSAReportStatus( + input: ChangeDSAReportStatusInput! + ): ChangeDSAReportStatusPayload! + + """ + makeDSAReportDecision makes a legality decision for a DSAReport and adds the decision to the history for a DSAReport + """ + makeDSAReportDecision( + input: MakeDSAReportDecisionInput! + ): MakeDSAReportDecisionPayload! } ################## diff --git a/server/src/core/server/index.ts b/server/src/core/server/index.ts index 74567c21d7..f1b99a9f0c 100644 --- a/server/src/core/server/index.ts +++ b/server/src/core/server/index.ts @@ -49,6 +49,7 @@ import { retrieveAllTenants, retrieveTenant, Tenant } from "./models/tenant"; import { WordListCategory } from "./services/comments/pipeline/phases/wordList/message"; import { WordListService } from "./services/comments/pipeline/phases/wordList/service"; import { ErrorReporter, SentryErrorReporter } from "./services/errors"; +import { InternalNotificationContext } from "./services/notifications/internal/context"; import { isInstalled } from "./services/tenant"; export interface ServerOptions { @@ -263,6 +264,11 @@ class Server { tenantCache: this.tenantCache, i18n: this.i18n, signingConfig: this.signingConfig, + notifications: new InternalNotificationContext( + this.mongo, + this.i18n, + logger + ), }); // Create the pubsub client. @@ -382,6 +388,7 @@ class Server { mongo: this.mongo, redis: this.redis, config: this.config, + i18n: this.i18n, mailerQueue: this.tasks.mailer, archiverQueue: this.tasks.archiver, tenantCache: this.tenantCache, diff --git a/server/src/core/server/locales/de-CH/common.ftl b/server/src/core/server/locales/de-CH/common.ftl index c9bc889352..d269633258 100644 --- a/server/src/core/server/locales/de-CH/common.ftl +++ b/server/src/core/server/locales/de-CH/common.ftl @@ -6,7 +6,6 @@ reaction-labelActiveRespected = Respektiert reaction-sortLabelMostRespected = Am meisten Respektiert comment-count = - { $number } { $number -> [one] Kommentar *[other] Kommentare diff --git a/server/src/core/server/locales/de/common.ftl b/server/src/core/server/locales/de/common.ftl index c9bc889352..d269633258 100644 --- a/server/src/core/server/locales/de/common.ftl +++ b/server/src/core/server/locales/de/common.ftl @@ -6,7 +6,6 @@ reaction-labelActiveRespected = Respektiert reaction-sortLabelMostRespected = Am meisten Respektiert comment-count = - { $number } { $number -> [one] Kommentar *[other] Kommentare diff --git a/server/src/core/server/locales/en-US/common.ftl b/server/src/core/server/locales/en-US/common.ftl index 1d5891bd36..81fedc75f1 100644 --- a/server/src/core/server/locales/en-US/common.ftl +++ b/server/src/core/server/locales/en-US/common.ftl @@ -18,3 +18,138 @@ comment-counts-ratings-and-reviews = } staff-label = Staff + +dsaReportCSV-timestamp = Timestamp (UTC) +dsaReportCSV-user = User +dsaReportCSV-action = Action +dsaReportCSV-details = Details +dsaReportCSV-reportSubmitted = Report submitted +dsaReportCSV-referenceID = Reference ID +dsaReportCSV-legalDetail = Legal detail +dsaReportCSV-additionalInfo = Additional info +dsaReportCSV-commentAuthor = Comment author +dsaReportCSV-commentBody = Comment body +dsaReportCSV-commentID = Comment ID +dsaReportCSV-commentMediaUrl = Comment media url +dsaReportCSV-changedStatus = Changed status +dsaReportCSV-addedNote = Added note +dsaReportCSV-madeDecision = Made decision +dsaReportCSV-downloadedReport = Downloaded report +dsaReportCSV-legality-illegal = Legality: Illegal +dsaReportCSV-legality-legal = Legality: Legal +dsaReportCSV-legalGrounds = Legal grounds +dsaReportCSV-explanation = Explanation +dsaReportCSV-status-awaitingReview = Awaiting review +dsaReportCSV-status-inReview = In review +dsaReportCSV-status-completed = Completed +dsaReportCSV-status-void = Void + +# Notifications + +notifications-illegalContentReportReviewed-title = + Your illegal content report has been reviewed + +notifications-illegalContentReportReviewed-decision-legal = + does not appear to contain illegal content +notifications-illegalContentReportReviewed-decision-illegal = + does contain illegal content + +notifications-illegalContentReportReviewed-description = + On { $date } you reported a comment written by { $author } for + containing illegal content. After reviewing your report, our moderation + team has decided this comment { $decision }. + +notifications-commentRejected-title = + Your comment has been rejected and removed from our site +notifications-commentRejected-description = + Our moderators have reviewed your comment and determined your comment contains content that violates our community guidelines or terms of service. +
+ { $details } + +notifications-commentRejected-details-illegalContent = + REASON FOR REMOVAL
+ { $reason }
+ LEGAL GROUNDS
+ { $grounds }
+ ADDITIONAL EXPLANATION
+ { $explanation } + +notifications-commentRejected-details-general = + REASON FOR REMOVAL
+ { $reason }
+ ADDITIONAL EXPLANATION
+ { $explanation } + +notification-reasonForRemoval-offensive = Offensive +notification-reasonForRemoval-abusive = Abusive +notification-reasonForRemoval-spam = Spam +notification-reasonForRemoval-bannedWord = Banned word +notification-reasonForRemoval-ad = Ad +notification-reasonForRemoval-other = Other +notification-reasonForRemoval-illegal = Illegal content +notification-reasonForRemoval-unknown = Unknown + +notifications-commentRejected-details-notFound = + Details for this rejection cannot be found. + +# Notifications (old) + +notifications-commentWasFeatured-title = Comment was featured +notifications-commentWasFeatured-body = The comment { $commentID } was featured. +notifications-commentWasApproved-title = Comment was approved +notifications-commentWasApproved-body = The comment { $commentID } was approved. + +notifications-commentWasRejected-title = Comment was rejected +notifications-commentWasRejected-body = The comment { $commentID } was rejected. + +notifications-commentWasRejectedWithReason-code = +
+ { $code } +notifications-commentWasRejectedWithReason-grounds = +
+ { $grounds } +notifications-commentWasRejectedWithReason-explanation = +
+ { $explanation } +notifications-commentWasRejectedWithReason-body = + The comment { $commentID } was rejected. + The reasons of which were: + { $code } + { $grounds } + { $explanation } + +notifications-commentWasRejectedAndIllegal-title = Comment was deemed to contain illegal content and was rejected +notifications-commentWasRejectedAndIllegal-body = + The comment { $commentID } was rejected for containing illegal content. + The reason of which was: +
+ { $reason } +notifications-dsaIllegalRejectedReason-information = + Grounds: +
+ { $grounds } +
+ Explanation: +
+ { $explanation } +notifications-dsaIllegalRejectedReason-informationNotFound = The reasoning for this decision cannot be found. + +notifications-dsaReportDecisionMade-title = A decision was made on your DSA report +notifications-dsaReportDecision-legal = The report { $reportID } was determined to be legal. +notifications-dsaReportDecision-illegal = The report { $reportID } was determined to be illegal. +notifications-dsaReportDecision-legalInformation = + Grounds: +
+ { $grounds } +
+ Explanation: +
+ { $explanation } +notifications-dsaReportDecisionMade-body-withoutInfo = { $decision } +notifications-dsaReportDecisionMade-body-withInfo = + { $decision } +
+ { $information } + +common-accountDeleted = + User account was deleted. diff --git a/server/src/core/server/locales/en-US/errors.ftl b/server/src/core/server/locales/en-US/errors.ftl index a4c01acb98..aa4eea1da2 100644 --- a/server/src/core/server/locales/en-US/errors.ftl +++ b/server/src/core/server/locales/en-US/errors.ftl @@ -22,6 +22,7 @@ error-duplicateUser = Specified user already exists with a different login method. error-duplicateEmail = Specified email address is already in use. error-duplicateEmailDomain = Specified email domain is already configured. +error-duplicateDSAReport = User has already reported this comment for illegal content. error-localProfileAlreadySet = Specified account already has a password set. error-localProfileNotSet = diff --git a/server/src/core/server/locales/fi-FI/common.ftl b/server/src/core/server/locales/fi-FI/common.ftl index 24dd7041a4..854b5a0332 100644 --- a/server/src/core/server/locales/fi-FI/common.ftl +++ b/server/src/core/server/locales/fi-FI/common.ftl @@ -6,7 +6,6 @@ reaction-labelActiveRespected = Hyvä kommentti reaction-sortLabelMostRespected = Parhaat kommentit comment-count = - { $number } { $number -> [one] kommentti *[other] kommenttia diff --git a/server/src/core/server/locales/fr-FR/common.ftl b/server/src/core/server/locales/fr-FR/common.ftl index bb10b59be2..9a624fc6f8 100755 --- a/server/src/core/server/locales/fr-FR/common.ftl +++ b/server/src/core/server/locales/fr-FR/common.ftl @@ -6,7 +6,6 @@ reaction-labelActiveRespected = Aimé reaction-sortLabelMostRespected = Le plus aimé comment-count = - { $number } { $number -> [one] Commentaire *[other] Commentaires diff --git a/server/src/core/server/locales/pl/common.ftl b/server/src/core/server/locales/pl/common.ftl index a17d9fd260..f15c136f25 100644 --- a/server/src/core/server/locales/pl/common.ftl +++ b/server/src/core/server/locales/pl/common.ftl @@ -6,7 +6,6 @@ reaction-labelActiveRespected = Polecany reaction-sortLabelMostRespected = Najbardziej polecane comment-count = - { $number } { $number -> [one] Komentarz [few] Komentarze diff --git a/server/src/core/server/locales/pt-BR/common.ftl b/server/src/core/server/locales/pt-BR/common.ftl index 0007f3f198..9e9f5dd65e 100644 --- a/server/src/core/server/locales/pt-BR/common.ftl +++ b/server/src/core/server/locales/pt-BR/common.ftl @@ -6,7 +6,6 @@ reaction-labelActiveRespected = Respeitado reaction-sortLabelMostRespected = Mais Respeitados comment-count = - { $number } { $number -> [one] Comentário *[other] Comentários diff --git a/server/src/core/server/locales/sv/common.ftl b/server/src/core/server/locales/sv/common.ftl index 537468ab41..95798c4989 100644 --- a/server/src/core/server/locales/sv/common.ftl +++ b/server/src/core/server/locales/sv/common.ftl @@ -6,7 +6,6 @@ reaction-labelActiveRespected = Respekterad reaction-sortLabelMostRespected = Mest respekterade comment-count = - { $number } { $number -> [one] kommentar *[other] kommentarer diff --git a/server/src/core/server/models/action/comment.ts b/server/src/core/server/models/action/comment.ts index 07c16b8778..9b95f759fa 100644 --- a/server/src/core/server/models/action/comment.ts +++ b/server/src/core/server/models/action/comment.ts @@ -23,6 +23,7 @@ import { GQLCOMMENT_SORT, GQLDontAgreeActionCounts, GQLFlagActionCounts, + GQLIllegalActionCounts, GQLReactionActionCounts, } from "coral-server/graph/schema/__generated__/types"; @@ -38,6 +39,11 @@ export enum ACTION_TYPE { */ DONT_AGREE = "DONT_AGREE", + /** + * ILLEGAL corresponds to when a user reports a comment as containing illegal content. + */ + ILLEGAL = "ILLEGAL", + /** * FLAG corresponds to a flag action that indicates that the given resource needs * moderator attention. @@ -66,6 +72,11 @@ export interface ActionCounts { * restricted to administrators and moderators. */ flag: FlagActionCounts; + + /** + * illegal returns the counts for the illegal content action on an item. + */ + illegal: GQLIllegalActionCounts; } /** @@ -147,6 +158,12 @@ export interface CommentAction extends TenantResource { * the section will be null here. */ section?: string; + + /** + * reportID is the id of the DSAReport that was created when the comment was flagged + as potentially containing illegal content. Only exists for illegal content flag type. + */ + reportID?: string; } const ActionSchema = Joi.compile([ @@ -165,6 +182,7 @@ const ActionSchema = Joi.compile([ { actionType: ACTION_TYPE.REACTION, }, + { actionType: ACTION_TYPE.ILLEGAL }, ]); /** @@ -218,6 +236,7 @@ export async function createAction( mongo: MongoContext, tenantID: string, input: CreateActionInput, + isArchived: boolean, now = new Date() ): Promise { const { metadata, additionalDetails, ...rest } = input; @@ -251,8 +270,13 @@ export async function createAction( $setOnInsert: action, }; + const collection = + mongo.archive && isArchived + ? mongo.archivedCommentActions() + : mongo.commentActions(); + // Insert the action into the database using an upsert operation. - const result = await mongo.commentActions().findOneAndUpdate(filter, update, { + const result = await collection.findOneAndUpdate(filter, update, { // We are using this to create a action, so we need to upsert it. upsert: true, @@ -283,11 +307,12 @@ export async function createActions( mongo: MongoContext, tenantID: string, inputs: CreateActionInput[], + isArchived: boolean, now = new Date() ): Promise { // TODO: (wyattjoh) replace with a batch write. return Promise.all( - inputs.map((input) => createAction(mongo, tenantID, input, now)) + inputs.map((input) => createAction(mongo, tenantID, input, isArchived, now)) ); } @@ -357,11 +382,14 @@ export async function retrieveManyUserActionPresence( commentActionsCache: CommentActionsCache, tenantID: string, userID: string | null, - commentIDs: string[] + commentIDs: string[], + useCache = true, + isArchived = false ): Promise { let actions: Readonly[] = []; - const cacheAvailable = await commentActionsCache.available(tenantID); + const cacheAvailable = + useCache && (await commentActionsCache.available(tenantID)); if (cacheAvailable) { const actionsFromCache = await commentActionsCache.findMany( tenantID, @@ -374,7 +402,11 @@ export async function retrieveManyUserActionPresence( } } } else { - const cursor = mongo.commentActions().find( + const collection = + mongo.archive && isArchived + ? mongo.archivedCommentActions() + : mongo.commentActions(); + const cursor = collection.find( { tenantID, userID, @@ -408,6 +440,7 @@ export async function retrieveManyUserActionPresence( reaction: false, dontAgree: false, flag: false, + illegal: false, } ) ); @@ -620,6 +653,9 @@ function createEmptyActionCounts(): ActionCounts { reaction: { total: 0, }, + illegal: { + total: 0, + }, dontAgree: { total: 0, }, @@ -707,6 +743,9 @@ function incrementActionCounts( case ACTION_TYPE.DONT_AGREE: actionCounts.dontAgree.total += count; break; + case ACTION_TYPE.ILLEGAL: + actionCounts.illegal.total += count; + break; case ACTION_TYPE.FLAG: // When we have a reason, we are incrementing for that particular reason // rather than incrementing for the total. If we don't have a reason, we diff --git a/server/src/core/server/models/action/moderation/comment.ts b/server/src/core/server/models/action/moderation/comment.ts index dde8ccea33..ef06a11ca8 100644 --- a/server/src/core/server/models/action/moderation/comment.ts +++ b/server/src/core/server/models/action/moderation/comment.ts @@ -10,7 +10,10 @@ import { } from "coral-server/models/helpers"; import { TenantResource } from "coral-server/models/tenant"; -import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; +import { + GQLCOMMENT_STATUS, + GQLREJECTION_REASON_CODE, +} from "coral-server/graph/schema/__generated__/types"; /** * CommentModerationAction stores information around a moderation action that @@ -41,6 +44,17 @@ export interface CommentModerationAction extends TenantResource { */ status: GQLCOMMENT_STATUS; + /** + * reason is the GQLMODERATION_REASON_REASON for the decision, if it is + * a rejection + */ + rejectionReason?: { + code: GQLREJECTION_REASON_CODE; + legalGrounds?: string; + detailedExplanation?: string; + customReason?: string; + }; + /** * moderatorID is the ID of the User that created the moderation action. If * null, it indicates that it was created by the system rather than a User. @@ -51,6 +65,12 @@ export interface CommentModerationAction extends TenantResource { * createdAt is the time that the moderation action was created on. */ createdAt: Date; + + /** + * reportID is the DSAReport on which an illegal content decision was made that led to + * this comment moderation action. + */ + reportID?: string; } export type CreateCommentModerationActionInput = Omit< diff --git a/server/src/core/server/models/comment/comment.ts b/server/src/core/server/models/comment/comment.ts index 295e04cdd4..b953b32e6a 100644 --- a/server/src/core/server/models/comment/comment.ts +++ b/server/src/core/server/models/comment/comment.ts @@ -927,9 +927,12 @@ export async function updateCommentActionCounts( tenantID: string, id: string, revisionID: string, - actionCounts: EncodedCommentActionCounts + actionCounts: EncodedCommentActionCounts, + isArchived = false ) { - const result = await mongo.comments().findOneAndUpdate( + const collection = + isArchived && mongo.archive ? mongo.archivedComments() : mongo.comments(); + const result = await collection.findOneAndUpdate( { id, tenantID }, // Update all the specific action counts that are associated with each of // the counts. diff --git a/server/src/core/server/models/dsaReport/index.ts b/server/src/core/server/models/dsaReport/index.ts new file mode 100644 index 0000000000..b25cb5d36f --- /dev/null +++ b/server/src/core/server/models/dsaReport/index.ts @@ -0,0 +1 @@ +export * from "./report"; diff --git a/server/src/core/server/models/dsaReport/report.ts b/server/src/core/server/models/dsaReport/report.ts new file mode 100644 index 0000000000..4b5b6d18a2 --- /dev/null +++ b/server/src/core/server/models/dsaReport/report.ts @@ -0,0 +1,586 @@ +import { isNumber } from "lodash"; +import { v4 as uuid } from "uuid"; + +import { Sub } from "coral-common/common/lib/types"; +import { MongoContext } from "coral-server/data/context"; +import { + CommentNotFoundError, + DuplicateDSAReportError, +} from "coral-server/errors"; +import { FindDSAReportInput } from "coral-server/graph/loaders/DSAReports"; + +import { + Connection, + ConnectionInput, + FilterQuery, + Query, + resolveConnection, +} from "coral-server/models/helpers"; +import { Tenant, TenantResource } from "coral-server/models/tenant"; + +import { + GQLDSAReportDecision, + GQLDSAReportDecisionLegality, + GQLDSAReportHistoryType, + GQLDSAReportStatus, + GQLREPORT_SORT, +} from "coral-server/graph/schema/__generated__/types"; + +export interface ReportHistoryItem { + /** + * id identifies this DSA Report history item specifically. + */ + id: string; + + /** + * createdAt is when this report history item was created + */ + createdAt: Date; + + /** + * createdBy is the id of the user who added the report history item + */ + createdBy: string; + + /** + * type is the kind of report history item (note added, status changed, decision made, etc.) + */ + type: GQLDSAReportHistoryType; + + /** + * body is the text of the note if the report history item is a note added + */ + body?: string; + + /** + * status is the new status if this report history item is a status change + */ + status?: GQLDSAReportStatus; + + /** + * decision is the legality decision made about the DSAReport + */ + decision?: GQLDSAReportDecision; +} + +export interface DSAReport extends TenantResource { + /** + * id identifies this DSA Report specifically. + */ + readonly id: string; + + /** + * userID is the id of the user who reported this comment for illegal content. + */ + userID: string; + + /** + * createdAt is the date that this DSAReport was created + */ + createdAt: Date; + + /** + * lawBrokenDescription is the description of the law this comment is being + * reported for breaking. + */ + lawBrokenDescription: string; + + /** + * additionalInformation is more explanation of how this comment being reported + * breaks the law. + */ + additionalInformation: string; + + /** + * commentID is the id of the comment being reported. + */ + commentID: string; + + /** + * submissionID is the id that keeps track of all comments that are submitted together + * as part of one illegal content report form by a user. + */ + submissionID: string; + + /** + * referenceID is a user-friendly id used to reference the DSA Report. + */ + referenceID: string; + + /** + * status keeps track of the current status of the DSA Report + */ + status: GQLDSAReportStatus; + + /** + * history keeps track of the history of a DSAReport, including notes added, when status is changed, + * and when an illegal content decision is made + */ + history: ReportHistoryItem[]; + + /** + * decision is the legality decision made about the DSAReport + */ + decision?: GQLDSAReportDecision; + + /** + * commentModerationActionID is the id of the comment moderation action associated with this DSAReport + */ + commentModerationActionID?: string; +} + +export type DSAReportConnectionInput = ConnectionInput & { + orderBy: GQLREPORT_SORT; +}; + +async function retrieveConnection( + input: DSAReportConnectionInput, + query: Query +): Promise>>> { + if (input.orderBy === GQLREPORT_SORT.CREATED_AT_ASC) { + query.orderBy({ createdAt: 1 }); + } else { + query.orderBy({ createdAt: -1 }); + } + + const skip = isNumber(input.after) ? input.after : 0; + if (skip) { + query.after(skip); + } + + if (input.filter) { + query.where(input.filter); + } + + // Return a connection. + return resolveConnection(query, input, (_, index) => index + skip + 1); +} + +export async function retrieveDSAReportConnection( + mongo: MongoContext, + tenantID: string, + input: DSAReportConnectionInput +): Promise>>> { + // Create the query. + const query = new Query(mongo.dsaReports()).where({ tenantID }); + + return retrieveConnection(input, query); +} + +export async function retrieveDSAReportRelatedReportsConnection( + mongo: MongoContext, + tenantID: string, + submissionID: string, + id: string, + input: DSAReportConnectionInput +): Promise>>> { + // Create the query. + const query = new Query(mongo.dsaReports()).where({ + tenantID, + submissionID, + id: { $ne: id }, + }); + + return retrieveConnection(input, query); +} + +export async function retrieveDSAReport( + mongo: MongoContext, + tenantID: string, + id: string +) { + return mongo.dsaReports().findOne({ tenantID, id }); +} + +export type CreateDSAReportInput = Omit< + DSAReport, + | "id" + | "tenantID" + | "createdAt" + | "referenceID" + | "status" + | "submissionID" + | "history" + | "decision" + | "commentModerationActionID" +> & { submissionID?: string }; + +export interface CreateDSAReportResultObject { + /** + * dsaReport contains the resultant DSAReport that was created. + */ + dsaReport: DSAReport; +} + +export async function createDSAReport( + mongo: MongoContext, + tenantID: string, + input: CreateDSAReportInput, + now = new Date() +): Promise { + const { userID, commentID, submissionID } = input; + + // Create a new ID for the DSAReport. + const id = uuid(); + let submissionIDToUse = submissionID; + if (!submissionIDToUse) { + submissionIDToUse = uuid(); + } + + // shorter, url-friendly referenceID generated from the report id, userID, and commentID + const referenceID = + userID.slice(0, 4) + "-" + commentID.slice(0, 4) + "-" + id.slice(0, 4); + + // defaults are the properties set by the application when a new DSAReport is + // created. + const defaults: Sub = { + id, + tenantID, + createdAt: now, + referenceID, + history: [], + status: GQLDSAReportStatus.AWAITING_REVIEW, + }; + + // Extract the filter parameters. + const filter: FilterQuery = { + tenantID, + commentID, + userID, + }; + + // Merge the defaults with the input. + const report: Readonly = { + ...defaults, + ...input, + submissionID: submissionIDToUse, + }; + + // check that a comment for the comment ID exists and throw an error if not + const commentExists = await mongo + .comments() + .findOne({ tenantID, id: commentID }); + + if (!commentExists && mongo.archive) { + // look in archived comments too + const commentIsArchived = await mongo + .archivedComments() + .findOne({ tenantID, id: commentID }); + if (!commentIsArchived) { + throw new CommentNotFoundError(commentID); + } + } + + // check if there's already a dsareport submitted by this user for this comment + // and return a duplicate error if so + const alreadyExistingReport = await mongo.dsaReports().findOne(filter); + + if (alreadyExistingReport) { + throw new DuplicateDSAReportError(alreadyExistingReport.id); + } + + await mongo.dsaReports().insertOne(report); + + return { + dsaReport: report, + }; +} + +export interface DSAReportNote { + id: string; + createdBy: string; + body: string; + createdAt: Date; +} + +export type CreateDSAReportNoteInput = Omit< + DSAReportNote, + "id" | "createdBy" | "createdAt" +> & { userID: string; reportID: string }; + +export interface CreateDSAReportNoteResultObject { + /** + * dsaReport contains the resultant DSAReport that was created. + */ + dsaReport: DSAReport; +} + +enum DSAReportHistoryType { + STATUS_CHANGED = "STATUS_CHANGED", + NOTE = "NOTE", + DECISION_MADE = "DECISION_MADE", + SHARE = "SHARE", +} + +export async function createDSAReportNote( + mongo: MongoContext, + tenantID: string, + input: CreateDSAReportNoteInput, + now = new Date() +): Promise { + const { userID, body, reportID } = input; + + // Create a new ID for the DSAReportNote. + const id = uuid(); + + const note = { + id, + createdBy: userID, + createdAt: now, + body, + type: DSAReportHistoryType.NOTE, + }; + + const updatedReport = await mongo.dsaReports().findOneAndUpdate( + { id: reportID, tenantID }, + { + $push: { + history: note, + }, + }, + { returnOriginal: false } + ); + + if (!updatedReport.value) { + throw new Error(); + } + + return { + dsaReport: updatedReport.value, + }; +} + +export interface CreateDSAReportShareInput { + reportID: string; + userID: string; +} + +export interface CreateDSAReportShareResultObject { + /** + * dsaReport contains the resultant DSAReport that was updated. + */ + dsaReport: DSAReport; +} + +export async function createDSAReportShare( + mongo: MongoContext, + tenantID: string, + input: CreateDSAReportShareInput, + now = new Date() +): Promise { + const { userID, reportID } = input; + + // Create a new ID for the DSAReportShare. + const id = uuid(); + + const note = { + id, + createdBy: userID, + createdAt: now, + type: DSAReportHistoryType.SHARE, + }; + + const updatedReport = await mongo.dsaReports().findOneAndUpdate( + { id: reportID, tenantID }, + { + $push: { + history: note, + }, + }, + { returnOriginal: false } + ); + + if (!updatedReport.value) { + throw new Error(); + } + + return { + dsaReport: updatedReport.value, + }; +} + +export interface DeleteDSAReportNoteInput { + id: string; + reportID: string; +} + +export interface DeleteDSAReportNoteResultObject { + /** + * dsaReport contains the resultant DSAReport from which a note was deleted from its history. + */ + dsaReport: DSAReport; +} + +export async function deleteDSAReportNote( + mongo: MongoContext, + tenantID: string, + input: DeleteDSAReportNoteInput +): Promise { + const { id, reportID } = input; + + const updatedReport = await mongo.dsaReports().findOneAndUpdate( + { id: reportID, tenantID }, + { + $pull: { + history: { id: { $eq: id } }, + }, + }, + { returnOriginal: false } + ); + + if (!updatedReport.value) { + throw new Error(); + } + + return { + dsaReport: updatedReport.value, + }; +} + +export interface ChangeDSAReportStatusInput { + reportID: string; + status: string; + userID: string; +} + +export interface ChangeDSAReportStatusResultObject { + /** + * dsaReport contains the resultant DSAReport that was updated. + */ + dsaReport: DSAReport; +} + +export async function changeDSAReportStatus( + mongo: MongoContext, + tenantID: string, + input: ChangeDSAReportStatusInput, + now = new Date() +): Promise { + const { userID, status, reportID } = input; + + // Create a new ID for the DSAReportHistoryItem. + const id = uuid(); + + const statusChangeHistoryItem = { + id, + createdBy: userID, + createdAt: now, + status, + type: DSAReportHistoryType.STATUS_CHANGED, + }; + + const updatedReport = await mongo.dsaReports().findOneAndUpdate( + { id: reportID, tenantID }, + { + $push: { + history: statusChangeHistoryItem, + }, + $set: { status }, + }, + { returnOriginal: false } + ); + + if (!updatedReport.value) { + throw new Error(); + } + + return { + dsaReport: updatedReport.value, + }; +} + +export interface MakeDSAReportDecisionInput { + reportID: string; + userID: string; + legality: GQLDSAReportDecisionLegality; + legalGrounds?: string; + detailedExplanation?: string; +} + +export interface MakeDSAReportDecisionResultObject { + /** + * dsaReport contains the resultant DSAReport that was updated. + */ + dsaReport: DSAReport; +} + +export async function makeDSAReportDecision( + mongo: MongoContext, + tenantID: string, + input: MakeDSAReportDecisionInput, + now = new Date() +): Promise { + const { userID, legality, legalGrounds, detailedExplanation, reportID } = + input; + + // Create new IDs for the DSAReportHistoryItems. + const statusChangeHistoryId = uuid(); + const decisionMadeHistoryId = uuid(); + + const statusChangeHistoryItem = { + id: statusChangeHistoryId, + createdBy: userID, + createdAt: now, + type: DSAReportHistoryType.STATUS_CHANGED, + status: GQLDSAReportStatus.COMPLETED, + }; + + const decisionMadeHistoryItem = { + id: decisionMadeHistoryId, + createdBy: userID, + createdAt: now, + type: DSAReportHistoryType.DECISION_MADE, + decision: { + legality, + legalGrounds, + detailedExplanation, + }, + }; + + const updatedReport = await mongo.dsaReports().findOneAndUpdate( + { id: reportID, tenantID }, + { + $push: { + history: { $each: [statusChangeHistoryItem, decisionMadeHistoryItem] }, + }, + $set: { + decision: { + legality, + legalGrounds, + detailedExplanation, + }, + status: GQLDSAReportStatus.COMPLETED, + }, + }, + { returnOriginal: false } + ); + + if (!updatedReport.value) { + throw new Error(); + } + + return { + dsaReport: updatedReport.value, + }; +} + +export async function find( + mongo: MongoContext, + tenant: Tenant, + input: FindDSAReportInput +) { + return findDSAReport(mongo, tenant.id, input.id); +} + +export async function findDSAReport( + mongo: MongoContext, + tenantID: string, + id: string +): Promise { + const result = await mongo.dsaReports().findOne({ + tenantID, + id, + }); + + return result ?? null; +} diff --git a/server/src/core/server/models/notifications/notification.ts b/server/src/core/server/models/notifications/notification.ts new file mode 100644 index 0000000000..cefe6d1bde --- /dev/null +++ b/server/src/core/server/models/notifications/notification.ts @@ -0,0 +1,119 @@ +import { MongoContext } from "coral-server/data/context"; + +import { + GQLCOMMENT_STATUS, + GQLNOTIFICATION_TYPE, + GQLNotificationDecisionDetails, + GQLREJECTION_REASON_CODE, +} from "coral-server/graph/schema/__generated__/types"; + +import { ConnectionInput, Query, resolveConnection } from "../helpers"; +import { TenantResource } from "../tenant"; +import { User } from "../user"; + +export interface Notification extends TenantResource { + readonly id: string; + readonly type: GQLNOTIFICATION_TYPE; + readonly tenantID: string; + + createdAt: Date; + + ownerID: string; + + reportID?: string; + + commentID?: string; + commentStatus?: GQLCOMMENT_STATUS; + + rejectionReason?: GQLREJECTION_REASON_CODE; + decisionDetails?: GQLNotificationDecisionDetails; + customReason?: string; +} + +type BaseConnectionInput = ConnectionInput; + +export interface NotificationsConnectionInput extends BaseConnectionInput { + ownerID: string; +} + +export const retrieveNotificationsConnection = async ( + mongo: MongoContext, + tenantID: string, + input: NotificationsConnectionInput +) => { + const query = new Query(mongo.notifications()).where({ + tenantID, + ownerID: input.ownerID, + }); + + query.orderBy({ createdAt: -1 }); + + if (input.after) { + query.where({ createdAt: { $lt: input.after as Date } }); + } + + return resolveConnection(query, input, (n) => n.createdAt); +}; + +export const createNotification = async ( + mongo: MongoContext, + notification: Notification +) => { + const op = await mongo.notifications().insertOne(notification); + + return op.result.ok ? notification : null; +}; + +interface LastSeenNotificationChange { + $set: { + lastSeenNotificationDate?: Date | null; + }; +} + +export const markLastSeenNotification = async ( + tenantID: string, + mongo: MongoContext, + user: Readonly, + notificationDates: Date[] +) => { + if (!notificationDates || notificationDates.length === 0) { + return; + } + + let max = new Date(0); + for (const date of notificationDates) { + if (max.getTime() < date.getTime()) { + max = date; + } + } + + const change: LastSeenNotificationChange = { + $set: { lastSeenNotificationDate: user.lastSeenNotificationDate }, + }; + + const thereAreNewNotifications = + user.lastSeenNotificationDate && + user.lastSeenNotificationDate.getTime() < max.getTime(); + const userHasNeverSeenNotifications = + user.lastSeenNotificationDate === null || + user.lastSeenNotificationDate === undefined; + + if (thereAreNewNotifications || userHasNeverSeenNotifications) { + change.$set.lastSeenNotificationDate = max; + } + + await mongo.users().findOneAndUpdate({ tenantID, id: user.id }, change); +}; + +export const hasNewNotifications = async ( + tenantID: string, + mongo: MongoContext, + ownerID: string, + lastSeen: Date +) => { + const exists = await mongo + .notifications() + .findOne({ tenantID, ownerID, createdAt: { $gt: lastSeen } }); + + return exists !== null; +}; diff --git a/server/src/core/server/models/settings/settings.ts b/server/src/core/server/models/settings/settings.ts index 99ba0821a9..d56eca99d1 100644 --- a/server/src/core/server/models/settings/settings.ts +++ b/server/src/core/server/models/settings/settings.ts @@ -2,6 +2,7 @@ import { GQLAuth, GQLAuthenticationTargetFilter, GQLCOMMENT_BODY_FORMAT, + GQLDSA_METHOD_OF_REDRESS, GQLEmailConfiguration, GQLFacebookAuthIntegration, GQLGoogleAuthIntegration, @@ -321,6 +322,7 @@ export type Settings = GlobalModerationSettings & | "announcement" | "memberBios" | "embeddedComments" + | "dsa" > & { /** * auth is the set of configured authentication integrations. @@ -417,3 +419,17 @@ export const defaultRTEConfiguration: RTEConfiguration = { spoiler: false, strikethrough: false, }; + +export interface DSAConfiguration { + enabled: boolean; + methodOfRedress: { + method: GQLDSA_METHOD_OF_REDRESS; + }; +} + +export const defaultDSAConfiguration: DSAConfiguration = { + enabled: false, + methodOfRedress: { + method: GQLDSA_METHOD_OF_REDRESS.NONE, + }, +}; diff --git a/server/src/core/server/models/tenant/tenant.ts b/server/src/core/server/models/tenant/tenant.ts index 4e6324523e..8191e47795 100644 --- a/server/src/core/server/models/tenant/tenant.ts +++ b/server/src/core/server/models/tenant/tenant.ts @@ -23,6 +23,7 @@ import { dotize } from "coral-server/utils/dotize"; import { GQLAnnouncement, + GQLDSA_METHOD_OF_REDRESS, GQLFEATURE_FLAG, GQLMODERATION_MODE, GQLSettings, @@ -293,11 +294,18 @@ export async function createTenant( emailDomainModeration: [], embeddedComments: { allowReplies: true, + oEmbedAllowedOrigins: [], }, flairBadges: { flairBadgesEnabled: false, badges: [], }, + dsa: { + enabled: false, + methodOfRedress: { + method: GQLDSA_METHOD_OF_REDRESS.NONE, + }, + }, }; // Create the new Tenant by merging it together with the defaults. diff --git a/server/src/core/server/models/user/user.ts b/server/src/core/server/models/user/user.ts index aa1b854db6..4488781bee 100644 --- a/server/src/core/server/models/user/user.ts +++ b/server/src/core/server/models/user/user.ts @@ -616,6 +616,12 @@ export interface User extends TenantResource { * bio is a user deifned biography */ bio?: string; + + /** + * lastSeenNotificationDate is the date of the last notification the user loaded (viewed) + * in their notification tab. + */ + lastSeenNotificationDate?: Date | null; } function hashPassword(password: string): Promise { diff --git a/server/src/core/server/queue/index.ts b/server/src/core/server/queue/index.ts index 00b27bb94f..0c6364a550 100644 --- a/server/src/core/server/queue/index.ts +++ b/server/src/core/server/queue/index.ts @@ -8,6 +8,7 @@ import { } from "coral-server/queue/tasks/loadCache"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis, createRedisClient, @@ -58,6 +59,7 @@ export interface QueueOptions { i18n: I18n; signingConfig: JWTSigningConfig; redis: AugmentedRedis; + notifications: InternalNotificationContext; } export interface TaskQueue { diff --git a/server/src/core/server/queue/tasks/notifier/index.ts b/server/src/core/server/queue/tasks/notifier/index.ts index f9cdff2eb5..ba2d15dee0 100644 --- a/server/src/core/server/queue/tasks/notifier/index.ts +++ b/server/src/core/server/queue/tasks/notifier/index.ts @@ -9,7 +9,7 @@ import { JWTSigningConfig } from "coral-server/services/jwt"; import { categories, NotificationCategory, -} from "coral-server/services/notifications/categories"; +} from "coral-server/services/notifications/email/categories"; import { TenantCache } from "coral-server/services/tenant/cache"; import { createJobProcessor, JOB_NAME, NotifierData } from "./processor"; diff --git a/server/src/core/server/queue/tasks/notifier/messages.ts b/server/src/core/server/queue/tasks/notifier/messages.ts index 9ab4de8151..76c1cdcfe1 100644 --- a/server/src/core/server/queue/tasks/notifier/messages.ts +++ b/server/src/core/server/queue/tasks/notifier/messages.ts @@ -1,8 +1,8 @@ import { CoralEventPayload } from "coral-server/events/event"; import logger from "coral-server/logger"; -import { NotificationCategory } from "coral-server/services/notifications/categories"; -import NotificationContext from "coral-server/services/notifications/context"; -import { Notification } from "coral-server/services/notifications/notification"; +import { NotificationCategory } from "coral-server/services/notifications/email/categories"; +import NotificationContext from "coral-server/services/notifications/email/context"; +import { Notification } from "coral-server/services/notifications/email/notification"; import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types"; diff --git a/server/src/core/server/queue/tasks/notifier/processor.ts b/server/src/core/server/queue/tasks/notifier/processor.ts index 07df2d1824..57b1afd647 100644 --- a/server/src/core/server/queue/tasks/notifier/processor.ts +++ b/server/src/core/server/queue/tasks/notifier/processor.ts @@ -6,9 +6,9 @@ import logger from "coral-server/logger"; import { JobProcessor } from "coral-server/queue/Task"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { JWTSigningConfig } from "coral-server/services/jwt"; -import { NotificationCategory } from "coral-server/services/notifications/categories"; -import NotificationContext from "coral-server/services/notifications/context"; -import { Notification } from "coral-server/services/notifications/notification"; +import { NotificationCategory } from "coral-server/services/notifications/email/categories"; +import NotificationContext from "coral-server/services/notifications/email/context"; +import { Notification } from "coral-server/services/notifications/email/notification"; import { TenantCache } from "coral-server/services/tenant/cache"; import { diff --git a/server/src/core/server/queue/tasks/rejector.ts b/server/src/core/server/queue/tasks/rejector.ts index fc1840bb03..ad66b8f1a3 100644 --- a/server/src/core/server/queue/tasks/rejector.ts +++ b/server/src/core/server/queue/tasks/rejector.ts @@ -14,6 +14,7 @@ import { retrieveAllCommentsUserConnection, retrieveCommentsBySitesUserConnection, } from "coral-server/services/comments"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { TenantCache } from "coral-server/services/tenant/cache"; import { rejectComment } from "coral-server/stacks"; @@ -21,7 +22,9 @@ import { rejectComment } from "coral-server/stacks"; import { GQLCOMMENT_SORT, GQLCOMMENT_STATUS, + GQLRejectionReason, } from "coral-server/graph/schema/__generated__/types"; +import { I18n } from "coral-server/services/i18n"; const JOB_NAME = "rejector"; @@ -30,6 +33,8 @@ export interface RejectorProcessorOptions { redis: AugmentedRedis; tenantCache: TenantCache; config: Config; + notifications: InternalNotificationContext; + i18n: I18n; } export interface RejectorData { @@ -37,6 +42,7 @@ export interface RejectorData { moderatorID: string; tenantID: string; siteIDs?: string[]; + reason?: GQLRejectionReason; } function getBatch( @@ -75,9 +81,11 @@ const rejectArchivedComments = async ( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, tenant: Readonly, authorID: string, moderatorID: string, + reason?: GQLRejectionReason, siteIDs?: string[] ) => { // Get the current time. @@ -101,6 +109,7 @@ const rejectArchivedComments = async ( commentRevisionID: revision.id, status: GQLCOMMENT_STATUS.REJECTED, moderatorID, + reason, }; const updateAllCommentCountsArgs = { @@ -118,6 +127,7 @@ const rejectArchivedComments = async ( mongo, redis, config, + i18n, tenant, input, now, @@ -149,9 +159,12 @@ const rejectLiveComments = async ( redis: AugmentedRedis, cache: DataCache, config: Config, + notifications: InternalNotificationContext, + i18n: I18n, tenant: Readonly, authorID: string, moderatorID: string, + reason?: GQLRejectionReason, siteIDs?: string[] ) => { // Get the current time. @@ -168,12 +181,15 @@ const rejectLiveComments = async ( redis, cache, config, + i18n, null, + notifications, tenant, comment.id, revision.id, moderatorID, - now + now, + reason ); } // If there was not another page, abort processing. @@ -197,10 +213,12 @@ const createJobProcessor = redis, tenantCache, config, + notifications, + i18n, }: RejectorProcessorOptions): JobProcessor => async (job) => { // Pull out the job data. - const { authorID, moderatorID, tenantID, siteIDs } = job.data; + const { authorID, moderatorID, tenantID, siteIDs, reason } = job.data; const log = logger.child( { jobID: job.id, @@ -238,9 +256,12 @@ const createJobProcessor = redis, cache, config, + notifications, + i18n, tenant, authorID, moderatorID, + reason, siteIDs ); if (mongo.archive) { @@ -248,9 +269,11 @@ const createJobProcessor = mongo, redis, config, + i18n, tenant, authorID, moderatorID, + reason, siteIDs ); } diff --git a/server/src/core/server/services/comments/actions.ts b/server/src/core/server/services/comments/actions.ts index 881baa6126..83bfc33f01 100644 --- a/server/src/core/server/services/comments/actions.ts +++ b/server/src/core/server/services/comments/actions.ts @@ -40,6 +40,7 @@ import { publishCommentFlagCreated, publishCommentReactionCreated, } from "../events"; +import { I18n } from "../i18n"; import { submitCommentAsSpam } from "../spam"; export type CreateAction = CreateActionInput; @@ -48,10 +49,17 @@ export async function addCommentActions( mongo: MongoContext, tenant: Tenant, inputs: CreateAction[], - now = new Date() + now = new Date(), + isArchived = false ) { // Create each of the actions, returning each of the action results. - const results = await createActions(mongo, tenant.id, inputs, now); + const results = await createActions( + mongo, + tenant.id, + inputs, + isArchived, + now + ); // Get the actions that were upserted, we only want to increment the action // counts of actions that were just created. @@ -64,7 +72,8 @@ export async function addCommentActionCounts( mongo: MongoContext, tenant: Tenant, oldComment: Readonly, - action: EncodedCommentActionCounts + action: EncodedCommentActionCounts, + isArchived = false ) { // Grab the last revision (the most recent). const revision = getLatestRevision(oldComment); @@ -75,7 +84,8 @@ export async function addCommentActionCounts( tenant.id, oldComment.id, revision.id, - action + action, + isArchived ); if (!updatedComment) { // TODO: (wyattjoh) return a better error. @@ -94,17 +104,30 @@ async function addCommentAction( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, broker: CoralEventPublisherBroker, tenant: Tenant, input: Omit, author: User, now = new Date() ): Promise { - const oldComment = await retrieveComment( + let oldComment = await retrieveComment( mongo.comments(), tenant.id, input.commentID ); + let isArchived = false; + if (!oldComment && mongo.archive) { + oldComment = await retrieveComment( + mongo.archivedComments(), + tenant.id, + input.commentID + ); + if (oldComment) { + isArchived = true; + } + } + if (!oldComment) { throw new CommentNotFoundError(input.commentID); } @@ -140,7 +163,13 @@ async function addCommentAction( }; // Update the actions for the comment. - const commentActions = await addCommentActions(mongo, tenant, [action], now); + const commentActions = await addCommentActions( + mongo, + tenant, + [action], + now, + isArchived + ); if (commentActions.length > 0) { // Get the comment action. const [commentAction] = commentActions; @@ -153,11 +182,12 @@ async function addCommentAction( mongo, tenant, oldComment, - actionCounts + actionCounts, + isArchived ); // Update the comment counts onto other documents. - const counts = await updateAllCommentCounts(mongo, redis, config, { + const counts = await updateAllCommentCounts(mongo, redis, config, i18n, { tenant, actionCounts, before: oldComment, @@ -165,12 +195,15 @@ async function addCommentAction( }); // Publish changes to the event publisher. - await publishChanges(broker, { - ...counts, - before: oldComment, - after: updatedComment, - commentRevisionID: input.commentRevisionID, - }); + // Do not publish if comment is archived + if (!isArchived) { + await publishChanges(broker, { + ...counts, + before: oldComment, + after: updatedComment, + commentRevisionID: input.commentRevisionID, + }); + } return { comment: updatedComment, action: commentAction }; } @@ -182,6 +215,7 @@ export async function removeCommentAction( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, cache: DataCache, broker: CoralEventPublisherBroker, tenant: Tenant, @@ -250,7 +284,7 @@ export async function removeCommentAction( } // Update the comment counts onto other documents. - const counts = await updateAllCommentCounts(mongo, redis, config, { + const counts = await updateAllCommentCounts(mongo, redis, config, i18n, { tenant, actionCounts, before: oldComment, @@ -280,6 +314,7 @@ export async function createReaction( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, cache: DataCache, broker: CoralEventPublisherBroker, tenant: Tenant, @@ -291,6 +326,7 @@ export async function createReaction( mongo, redis, config, + i18n, broker, tenant, { @@ -331,18 +367,28 @@ export async function removeReaction( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, cache: DataCache, broker: CoralEventPublisherBroker, tenant: Tenant, author: User, input: RemoveCommentReaction ) { - return removeCommentAction(mongo, redis, config, cache, broker, tenant, { - actionType: ACTION_TYPE.REACTION, - commentID: input.commentID, - commentRevisionID: input.commentRevisionID, - userID: author.id, - }); + return removeCommentAction( + mongo, + redis, + config, + i18n, + cache, + broker, + tenant, + { + actionType: ACTION_TYPE.REACTION, + commentID: input.commentID, + commentRevisionID: input.commentRevisionID, + userID: author.id, + } + ); } export type CreateCommentDontAgree = Pick< @@ -354,6 +400,7 @@ export async function createDontAgree( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, commentActionsCache: CommentActionsCache, broker: CoralEventPublisherBroker, tenant: Tenant, @@ -365,6 +412,7 @@ export async function createDontAgree( mongo, redis, config, + i18n, broker, tenant, { @@ -385,6 +433,59 @@ export async function createDontAgree( return comment; } +export type CreateIllegalContent = Pick & { + commentRevisionID?: string; + reportID?: string; +}; + +export async function createIllegalContent( + mongo: MongoContext, + redis: AugmentedRedis, + config: Config, + i18n: I18n, + commentActionsCache: CommentActionsCache, + broker: CoralEventPublisherBroker, + tenant: Tenant, + user: User, + comment: Readonly | null, + input: CreateIllegalContent, + now = new Date() +) { + let revisionID = input.commentRevisionID; + + if (!comment) { + throw new CommentNotFoundError(input.commentID); + } + + if (!revisionID) { + revisionID = getLatestRevision(comment).id; + } + + const { comment: commentUpdated, action } = await addCommentAction( + mongo, + redis, + config, + i18n, + broker, + tenant, + { + actionType: ACTION_TYPE.ILLEGAL, + commentID: input.commentID, + commentRevisionID: revisionID, + reportID: input.reportID, + }, + user, + now + ); + + const cacheAvailable = await commentActionsCache.available(tenant.id); + if (action && cacheAvailable) { + await commentActionsCache.add(action); + } + + return commentUpdated; +} + export type RemoveCommentDontAgree = Pick< RemoveActionInput, "commentID" | "commentRevisionID" @@ -394,18 +495,28 @@ export async function removeDontAgree( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, cache: DataCache, broker: CoralEventPublisherBroker, tenant: Tenant, author: User, input: RemoveCommentDontAgree ) { - return removeCommentAction(mongo, redis, config, cache, broker, tenant, { - actionType: ACTION_TYPE.DONT_AGREE, - commentID: input.commentID, - commentRevisionID: input.commentRevisionID, - userID: author.id, - }); + return removeCommentAction( + mongo, + redis, + config, + i18n, + cache, + broker, + tenant, + { + actionType: ACTION_TYPE.DONT_AGREE, + commentID: input.commentID, + commentRevisionID: input.commentRevisionID, + userID: author.id, + } + ); } export type CreateCommentFlag = Pick< @@ -419,6 +530,7 @@ export async function createFlag( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, commentActionsCache: CommentActionsCache, broker: CoralEventPublisherBroker, tenant: Tenant, @@ -431,6 +543,7 @@ export async function createFlag( mongo, redis, config, + i18n, broker, tenant, { diff --git a/server/src/core/server/services/comments/moderation/moderate.spec.ts b/server/src/core/server/services/comments/moderation/moderate.spec.ts new file mode 100644 index 0000000000..e9bc849995 --- /dev/null +++ b/server/src/core/server/services/comments/moderation/moderate.spec.ts @@ -0,0 +1,85 @@ +import { Config } from "coral-server/config"; +import { + createCommentFixture, + createStoryFixture, + createTenantFixture, + createUserFixture, +} from "coral-server/test/fixtures"; +import { + createMockMongoContex, + createMockRedis, +} from "coral-server/test/mocks"; +import moderate, { Moderate } from "./moderate"; + +import { + GQLCOMMENT_STATUS, + GQLDSA_METHOD_OF_REDRESS, + GQLUSER_ROLE, +} from "coral-server/graph/schema/__generated__/types"; +import { I18n } from "coral-server/services/i18n"; + +jest.mock("coral-server/models/comment/comment"); +jest.mock("coral-server/stacks/helpers"); +jest.mock("coral-server/models/action/moderation/comment"); + +it("requires a valid rejection reason if dsaFeatures are enabled", async () => { + const tenant = createTenantFixture({ + dsa: { + enabled: true, + methodOfRedress: { method: GQLDSA_METHOD_OF_REDRESS.NONE }, + }, + }); + const config = {} as Config; + const story = createStoryFixture({ tenantID: tenant.id }); + const comment = createCommentFixture({ storyID: story.id }); + const moderator = createUserFixture({ + tenantID: tenant.id, + role: GQLUSER_ROLE.MODERATOR, + }); + const { ctx: mongoContext } = createMockMongoContex(); + const redis = createMockRedis(); + + /* eslint-disable-next-line */ + require("coral-server/models/comment/comment").retrieveComment.mockImplementation( + async () => comment + ); + + /* eslint-disable-next-line */ + require("coral-server/models/comment/comment").updateCommentStatus.mockImplementation( + async () => ({}) + ); + + /* eslint-disable-next-line */ + require("coral-server/models/action/moderation/comment").createCommentModerationAction.mockImplementation( + async () => ({}) + ); + + /* eslint-disable-next-line */ + require("coral-server/stacks/helpers").updateAllCommentCounts.mockImplementation( + async () => ({}) + ); + + const input: Moderate = { + commentID: comment.id, + moderatorID: moderator.id, + commentRevisionID: comment.revisions[comment.revisions.length - 1].id, + status: GQLCOMMENT_STATUS.REJECTED, + }; + + await expect( + async () => + await moderate( + mongoContext, + redis, + config, + new I18n("en-US"), + tenant, + input, + new Date(), + false, + { + actionCounts: {}, + } + ) + ).rejects.toThrow(); +}); diff --git a/server/src/core/server/services/comments/moderation/moderate.ts b/server/src/core/server/services/comments/moderation/moderate.ts index 2c8cd5a102..45e6abaff4 100644 --- a/server/src/core/server/services/comments/moderation/moderate.ts +++ b/server/src/core/server/services/comments/moderation/moderate.ts @@ -1,9 +1,12 @@ +import { ERROR_CODES } from "coral-common/common/lib/errors"; import { Config } from "coral-server/config"; import { MongoContext } from "coral-server/data/context"; import { CommentNotFoundError, CommentRevisionNotFoundError, + OperationForbiddenError, } from "coral-server/errors"; +import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; import { EncodedCommentActionCounts } from "coral-server/models/action/comment"; import { createCommentModerationAction, @@ -16,6 +19,7 @@ import { updateCommentStatus, } from "coral-server/models/comment"; import { Tenant } from "coral-server/models/tenant"; +import { I18n } from "coral-server/services/i18n"; import { AugmentedRedis } from "coral-server/services/redis"; import { updateAllCommentCounts } from "coral-server/stacks/helpers"; @@ -25,6 +29,7 @@ export default async function moderate( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, tenant: Tenant, input: Moderate, now: Date, @@ -39,6 +44,18 @@ export default async function moderate( }; } ) { + if ( + tenant.dsa?.enabled && + input.status === GQLCOMMENT_STATUS.REJECTED && + !input.rejectionReason + ) { + throw new OperationForbiddenError( + ERROR_CODES.VALIDATION, + "DSA features enabled, rejection reason is required", + "comment", + "moderate" + ); + } // TODO: wrap these operations in a transaction? const commentsColl = isArchived && mongo.archive ? mongo.archivedComments() : mongo.comments(); @@ -113,6 +130,7 @@ export default async function moderate( mongo, redis, config, + i18n, { ...result, tenant, diff --git a/server/src/core/server/services/comments/pipeline/helpers.ts b/server/src/core/server/services/comments/pipeline/helpers.ts index 3c9738ca13..2f859b04b1 100644 --- a/server/src/core/server/services/comments/pipeline/helpers.ts +++ b/server/src/core/server/services/comments/pipeline/helpers.ts @@ -6,12 +6,16 @@ export function mergePhaseResult( result: Partial, final: Partial ) { - const { actions = [], tags = [], metadata = {} } = final; + const { commentActions = [], tags = [], metadata = {} } = final; // If this result contained actions, then we should push it into the // other actions. - if (result.actions) { - final.actions = [...actions, ...result.actions]; + if (result.commentActions) { + final.commentActions = [...commentActions, ...result.commentActions]; + } + + if (result.moderationAction) { + final.moderationAction = result.moderationAction; } // If this result contained metadata, then we should merge it into the diff --git a/server/src/core/server/services/comments/pipeline/phases/detectLinks.ts b/server/src/core/server/services/comments/pipeline/phases/detectLinks.ts index 8833307ed6..dc0914e00f 100755 --- a/server/src/core/server/services/comments/pipeline/phases/detectLinks.ts +++ b/server/src/core/server/services/comments/pipeline/phases/detectLinks.ts @@ -31,7 +31,7 @@ export const detectLinks: IntermediateModerationPhase = ({ // Add the flag related to Trust to the comment. return { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, diff --git a/server/src/core/server/services/comments/pipeline/phases/external.ts b/server/src/core/server/services/comments/pipeline/phases/external.ts index ed1f7c5b7a..bfc8103941 100644 --- a/server/src/core/server/services/comments/pipeline/phases/external.ts +++ b/server/src/core/server/services/comments/pipeline/phases/external.ts @@ -103,8 +103,8 @@ export interface ExternalModerationRequest { } export type ExternalModerationResponse = Partial< - Pick ->; + Pick +> & { actions: PhaseResult["commentActions"] }; const ExternalModerationResponseSchema = Joi.object().keys({ actions: Joi.array().items( @@ -267,12 +267,27 @@ async function processPhase( return validateResponse(body); } +/** + * Our external API still just has a concept of "actions", while + * internally we distinguish beteween "moderationActions" and "commentActions" + */ +const mapActions = ( + response: ExternalModerationResponse +): Partial => { + return { + ...response, + commentActions: response.actions, + }; +}; + export const external: IntermediateModerationPhase = async (ctx) => { // Check to see if any custom moderation phases have been defined, if there is // none, exit now. if ( !ctx.tenant.integrations.external || - ctx.tenant.integrations.external.phases.length === 0 + ctx.tenant.integrations.external.phases.length === 0 || + // (marcushaddon) DSA and external moderation are mutually exclusive for the time being + ctx.tenant.dsa?.enabled ) { return; } @@ -317,8 +332,10 @@ export const external: IntermediateModerationPhase = async (ctx) => { }, }); + const mappedResponse = mapActions(response); + // Merge the results in. If we're finished, return now! - const finished = mergePhaseResult(response, result); + const finished = mergePhaseResult(mappedResponse, result); if (finished) { return result; } diff --git a/server/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts b/server/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts index b199917129..74d9a88d66 100644 --- a/server/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts +++ b/server/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts @@ -47,7 +47,7 @@ export const recentCommentHistory = async ({ if (rate >= tenant.recentCommentHistory.triggerRejectionRate) { return { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_RECENT_HISTORY, diff --git a/server/src/core/server/services/comments/pipeline/phases/repeatPost.ts b/server/src/core/server/services/comments/pipeline/phases/repeatPost.ts index 00245be9ce..3dfeb1ba87 100644 --- a/server/src/core/server/services/comments/pipeline/phases/repeatPost.ts +++ b/server/src/core/server/services/comments/pipeline/phases/repeatPost.ts @@ -106,7 +106,7 @@ export const repeatPost: IntermediateModerationPhase = async ({ return { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_REPEAT_POST, diff --git a/server/src/core/server/services/comments/pipeline/phases/spam.ts b/server/src/core/server/services/comments/pipeline/phases/spam.ts index e1c381cbef..e8d7a38014 100644 --- a/server/src/core/server/services/comments/pipeline/phases/spam.ts +++ b/server/src/core/server/services/comments/pipeline/phases/spam.ts @@ -114,7 +114,7 @@ export const spam: IntermediateModerationPhase = async ({ return { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM, diff --git a/server/src/core/server/services/comments/pipeline/phases/statusPreModerateNewCommenter.ts b/server/src/core/server/services/comments/pipeline/phases/statusPreModerateNewCommenter.ts index 41d80b11af..1b164b1dfe 100644 --- a/server/src/core/server/services/comments/pipeline/phases/statusPreModerateNewCommenter.ts +++ b/server/src/core/server/services/comments/pipeline/phases/statusPreModerateNewCommenter.ts @@ -72,7 +72,7 @@ export const statusPreModerateNewCommenter = async ({ return { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_NEW_COMMENTER, diff --git a/server/src/core/server/services/comments/pipeline/phases/toxic.ts b/server/src/core/server/services/comments/pipeline/phases/toxic.ts index 2897ed08a3..ef39f49f9d 100644 --- a/server/src/core/server/services/comments/pipeline/phases/toxic.ts +++ b/server/src/core/server/services/comments/pipeline/phases/toxic.ts @@ -128,7 +128,7 @@ export const toxic: IntermediateModerationPhase = async ({ return { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, diff --git a/server/src/core/server/services/comments/pipeline/phases/wordList/phase.ts b/server/src/core/server/services/comments/pipeline/phases/wordList/phase.ts index 34e8174b49..d440630d11 100644 --- a/server/src/core/server/services/comments/pipeline/phases/wordList/phase.ts +++ b/server/src/core/server/services/comments/pipeline/phases/wordList/phase.ts @@ -3,6 +3,7 @@ import { ACTION_TYPE } from "coral-server/models/action/comment"; import { GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, + GQLREJECTION_REASON_CODE, } from "coral-server/graph/schema/__generated__/types"; import { @@ -31,12 +32,19 @@ export const wordListPhase: IntermediateModerationPhase = async ({ if (banned.isMatched) { return { status: GQLCOMMENT_STATUS.REJECTED, - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, }, ], + moderationAction: { + status: GQLCOMMENT_STATUS.REJECTED, + moderatorID: null, + rejectionReason: { + code: GQLREJECTION_REASON_CODE.BANNED_WORD, + }, + }, metadata: { wordList: { bannedWords: banned.matches, @@ -45,7 +53,7 @@ export const wordListPhase: IntermediateModerationPhase = async ({ }; } else if (banned.timedOut) { return { - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, @@ -68,7 +76,7 @@ export const wordListPhase: IntermediateModerationPhase = async ({ if (tenant.premoderateSuspectWords && suspect.isMatched) { return { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD, @@ -82,7 +90,7 @@ export const wordListPhase: IntermediateModerationPhase = async ({ }; } else if (suspect.isMatched) { return { - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD, @@ -96,7 +104,7 @@ export const wordListPhase: IntermediateModerationPhase = async ({ }; } else if (suspect.timedOut) { return { - actions: [ + commentActions: [ { actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD, diff --git a/server/src/core/server/services/comments/pipeline/phases/wordList/worker.ts b/server/src/core/server/services/comments/pipeline/phases/wordList/worker.ts index bdbf8445bc..6c6689edf9 100644 --- a/server/src/core/server/services/comments/pipeline/phases/wordList/worker.ts +++ b/server/src/core/server/services/comments/pipeline/phases/wordList/worker.ts @@ -21,6 +21,7 @@ interface List { category: WordListCategory; locale: LanguageCode; regex: RE2 | null; + regexIsEmpty: boolean; } const lists = new Map(); @@ -42,7 +43,13 @@ const initialize = ( const regex = phrases.length > 0 ? createServerWordListRegEx(locale, phrases) : null; - lists.set(key, { tenantID, category, locale, regex }); + lists.set(key, { + tenantID, + category, + locale, + regex, + regexIsEmpty: phrases.length === 0, + }); logger.info( { tenantID, category, phrases: phrases.length }, @@ -73,7 +80,33 @@ const process = ( const listKey = computeWordListKey(tenantID, category); const list = lists.get(listKey); - if (!list || list.regex === null) { + if (!list) { + return { + id, + tenantID, + ok: false, + err: new Error("word list for tenant not found"), + }; + } + + // Handle the case a phrase list is empty. + // If the regex is empty, we had no phrases to match against + // return that there are no matches as there can't be any matches. + if (list.regexIsEmpty) { + return { + id, + tenantID, + ok: true, + data: { + isMatched: false, + matches: [], + }, + }; + } + + // If we made it here, the regex must be valid or something + // has gone very wrong! + if (!list.regex) { return { id, tenantID, diff --git a/server/src/core/server/services/comments/pipeline/pipeline.spec.ts b/server/src/core/server/services/comments/pipeline/pipeline.spec.ts index 26abc5d25d..fba3d9068a 100644 --- a/server/src/core/server/services/comments/pipeline/pipeline.spec.ts +++ b/server/src/core/server/services/comments/pipeline/pipeline.spec.ts @@ -31,7 +31,7 @@ describe("compose", () => { body: context.comment.body, status, metadata: {}, - actions: [], + commentActions: [], tags: [], }); }); @@ -48,7 +48,7 @@ describe("compose", () => { body: context.comment.body, status, metadata: { akismet: false, linkCount: 1 }, - actions: [], + commentActions: [], tags: [], }); }); @@ -71,14 +71,14 @@ describe("compose", () => { const enhanced = compose([ () => ({ - actions: [flags[0]], + commentActions: [flags[0]], }), () => ({ status, - actions: [flags[1]], + commentActions: [flags[1]], }), () => ({ - actions: [ + commentActions: [ { userID: null, actionType: ACTION_TYPE.FLAG, @@ -91,10 +91,10 @@ describe("compose", () => { const final = await enhanced(context); for (const flag of flags) { - expect(final.actions).toContainEqual(flag); + expect(final.commentActions).toContainEqual(flag); } - expect(final.actions).not.toContainEqual({ + expect(final.commentActions).not.toContainEqual({ body: context.comment.body, actionType: ACTION_TYPE.FLAG, reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS, @@ -111,7 +111,7 @@ describe("compose", () => { body: context.comment.body, status: GQLCOMMENT_STATUS.NONE, metadata: { akismet: false }, - actions: [], + commentActions: [], tags: [], }); }); diff --git a/server/src/core/server/services/comments/pipeline/pipeline.ts b/server/src/core/server/services/comments/pipeline/pipeline.ts index d9e5e4fa61..66f6e24abe 100644 --- a/server/src/core/server/services/comments/pipeline/pipeline.ts +++ b/server/src/core/server/services/comments/pipeline/pipeline.ts @@ -4,6 +4,7 @@ import { Config } from "coral-server/config"; import { MongoContext } from "coral-server/data/context"; import { Logger } from "coral-server/logger"; import { CreateActionInput } from "coral-server/models/action/comment"; +import { CreateCommentModerationActionInput } from "coral-server/models/action/moderation/comment"; import { Comment, CreateCommentInput, @@ -26,16 +27,26 @@ import { mergePhaseResult } from "./helpers"; import { moderationPhases } from "./phases"; import { WordListService } from "./phases/wordList/service"; -export type ModerationAction = Omit< +export type CommentAction = Omit< CreateActionInput, "commentID" | "commentRevisionID" | "storyID" | "siteID" | "userID" >; +export type ModerationAction = Omit< + CreateCommentModerationActionInput, + "commentID" | "commentRevisionID" | "storyID" | "siteID" | "userID" +>; + export interface PhaseResult { /** - * actions are moderation actions that are added to the comment revision. + * moderationActions are moderation actions that are added to the comment revision. + */ + moderationAction?: ModerationAction; + + /** + * commentActions are comment actions that are added to the comment revision. */ - actions: ModerationAction[]; + commentActions: CommentAction[]; /** * status when provided decides and terminates the moderation process by @@ -122,7 +133,7 @@ export const compose = const final: PhaseResult = { status: GQLCOMMENT_STATUS.NONE, body: context.comment.body, - actions: [], + commentActions: [], metadata: { // Merge in the passed comment metadata. ...(context.comment.metadata || {}), diff --git a/server/src/core/server/services/dsaReports/download.ts b/server/src/core/server/services/dsaReports/download.ts new file mode 100644 index 0000000000..a4cc7dd54c --- /dev/null +++ b/server/src/core/server/services/dsaReports/download.ts @@ -0,0 +1,252 @@ +import archiver from "archiver"; +import stringify from "csv-stringify"; +import { Response } from "express"; + +import { createDateFormatter } from "coral-common/common/lib/date"; +import { MongoContext } from "coral-server/data/context"; +import { GQLDSAReportStatus } from "coral-server/graph/schema/__generated__/types"; +import { retrieveComment } from "coral-server/models/comment"; +import { DSAReport } from "coral-server/models/dsaReport"; +import { Tenant } from "coral-server/models/tenant"; +import { retrieveUser } from "coral-server/models/user"; + +import { I18n, translate } from "../i18n"; + +export async function sendReportDownload( + res: Response, + mongo: MongoContext, + i18n: I18n, + tenant: Tenant, + report: Readonly, + now: Date +) { + const bundle = i18n.getBundle(tenant.locale); + + // Create the date formatter to format the dates for the CSV. + const formatter = createDateFormatter(tenant.locale, { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: false, + }); + + // Generate the filename of the file that the user will download. + const filename = `coral-dsaReport-${report.referenceID}-${Math.abs( + now.getTime() + )}.zip`; + + res.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename=${filename}`, + }); + + // Create the zip archive we'll use to write all the exported files to. + const archive = archiver("zip", { + zlib: { level: 9 }, + }); + + // Pipe this to the response writer directly. + archive.pipe(res); + + // Create all the csv writers that'll write the data to the archive. + const csv = stringify(); + + // Add all the streams as files to the archive. + archive.append(csv, { + name: `report-${report.referenceID}-${Math.abs(now.getTime())}.csv`, + }); + + const reporter = await retrieveUser(mongo, tenant.id, report.userID); + + let reportedComment = await retrieveComment( + mongo.comments(), + tenant.id, + report.commentID + ); + if (!reportedComment && mongo.archive) { + reportedComment = await retrieveComment( + mongo.archivedComments(), + tenant.id, + report.commentID + ); + } + + let reportedCommentAuthorUsername = ""; + if (reportedComment) { + if (reportedComment.authorID) { + const reportedCommentAuthor = await retrieveUser( + mongo, + tenant.id, + reportedComment.authorID + ); + reportedCommentAuthorUsername = + reportedCommentAuthor && reportedCommentAuthor.username + ? reportedCommentAuthor.username + : ""; + } + } + + csv.write([ + translate(bundle, "Timestamp (UTC)", "dsaReportCSV-timestamp"), + translate(bundle, "User", "dsaReportCSV-user"), + translate(bundle, "Action", "dsaReportCSV-action"), + translate(bundle, "Details", "dsaReportCSV-details"), + ]); + + // Set up default report info cell + let reportInfo = `${translate( + bundle, + "Reference ID", + "dsaReportCSV-referenceID" + )}: ${report.referenceID}\n${translate( + bundle, + "Legal detail", + "dsaReportCSV-legalDetail" + )}: ${report.lawBrokenDescription}\n${translate( + bundle, + "Additional info", + "dsaReportCSV-additionalInfo" + )}: ${report.additionalInformation}`; + + // Add reported comment info to report info cell if available + if (reportedComment && report.status !== GQLDSAReportStatus.VOID) { + reportInfo += `\n${translate( + bundle, + "Comment author", + "dsaReportCSV-commentAuthor" + )}: ${reportedCommentAuthorUsername}\n${translate( + bundle, + "Comment body", + "dsaReportCSV-commentBody" + )}: ${reportedComment.revisions[0].body}\n${translate( + bundle, + "Comment ID", + "dsaReportCSV-commentID" + )}: ${reportedComment.id}`; + + // Add in comment media url if present + const commentMediaUrl = reportedComment.revisions[0].media?.url; + if (commentMediaUrl) { + reportInfo += `\n${translate( + bundle, + "Comment media url", + "dsaReportCSV-commentMediaUrl" + )}: ${commentMediaUrl}`; + } + } + + // Write report info cell data to CSV + csv.write([ + formatter.format(report.createdAt), + reporter?.username, + translate(bundle, "Report submitted", "dsaReportCSV-reportSubmitted"), + reportInfo, + ]); + + if (report.history) { + const getStatusText = (status: GQLDSAReportStatus) => { + const mapping = { + AWAITING_REVIEW: { + text: "Awaiting review", + id: "dsaReportCSV-status-awaitingReview", + }, + UNDER_REVIEW: { + text: "In review", + id: "dsaReportCSV-status-inReview", + }, + COMPLETED: { text: "Completed", id: "dsaReportCSV-status-completed" }, + VOID: { text: "Void", id: "dsaReportCSV-status-void" }, + }; + return mapping[status]; + }; + for (const reportHistoryItem of report.history) { + const reportCommentAuthor = await retrieveUser( + mongo, + tenant.id, + reportHistoryItem.createdBy + ); + switch (reportHistoryItem.type) { + case "STATUS_CHANGED": + csv.write([ + formatter.format(reportHistoryItem.createdAt), + reportCommentAuthor?.username, + translate(bundle, "Changed status", "dsaReportCSV-changedStatus"), + reportHistoryItem.status + ? translate( + bundle, + getStatusText(reportHistoryItem.status).text, + getStatusText(reportHistoryItem.status).id + ) + : reportHistoryItem.status, + ]); + break; + case "NOTE": + csv.write([ + formatter.format(reportHistoryItem.createdAt), + reportCommentAuthor?.username, + translate(bundle, "Added note", "dsaReportCSV-addedNote"), + reportHistoryItem.body, + ]); + break; + case "DECISION_MADE": + { + const details = + reportHistoryItem.decision?.legality === "ILLEGAL" + ? `${translate( + bundle, + "Legality: Illegal", + "dsaReportCSV-legality-illegal" + )}\n${translate( + bundle, + "Legal grounds", + "dsaReportCSV-legalGrounds" + )}: ${reportHistoryItem.decision.legalGrounds}\n${translate( + bundle, + "Explanation", + "dsaReportCSV-explanation" + )}: ${reportHistoryItem.decision.detailedExplanation}` + : `${translate( + bundle, + "Legality: Legal", + "dsaReportCSV-legality-legal" + )}`; + csv.write([ + formatter.format(reportHistoryItem.createdAt), + reportCommentAuthor?.username, + translate(bundle, "Made decision", "dsaReportCSV-madeDecision"), + details, + ]); + } + break; + case "SHARE": + csv.write([ + formatter.format(reportHistoryItem.createdAt), + reportCommentAuthor?.username, + translate( + bundle, + "Downloaded report", + "dsaReportCSV-downloadedReport" + ), + "", + ]); + break; + default: + csv.write([ + formatter.format(reportHistoryItem.createdAt), + reportCommentAuthor?.username, + "", + ]); + } + } + } + + csv.end(); + + // Mark the end of adding files, no more files can be added after this. Once + // all the stream readers have finished writing, and have closed, the + // archiver will close which will finish the HTTP request. + await archive.finalize(); +} diff --git a/server/src/core/server/services/dsaReports/index.ts b/server/src/core/server/services/dsaReports/index.ts new file mode 100644 index 0000000000..3bcc5bf220 --- /dev/null +++ b/server/src/core/server/services/dsaReports/index.ts @@ -0,0 +1,2 @@ +export * from "./download"; +export * from "./reports"; diff --git a/server/src/core/server/services/dsaReports/reports.ts b/server/src/core/server/services/dsaReports/reports.ts new file mode 100644 index 0000000000..caa303adba --- /dev/null +++ b/server/src/core/server/services/dsaReports/reports.ts @@ -0,0 +1,274 @@ +import { Config } from "coral-server/config"; +import { DataCache } from "coral-server/data/cache/dataCache"; +import { MongoContext } from "coral-server/data/context"; +import { CommentNotFoundError, StoryNotFoundError } from "coral-server/errors"; +import { CoralEventPublisherBroker } from "coral-server/events/publisher"; +import { Comment } from "coral-server/models/comment"; +import { + changeDSAReportStatus as changeReportStatus, + createDSAReport as createReport, + createDSAReportNote as createReportNote, + createDSAReportShare as createReportShare, + deleteDSAReportNote as deleteReportNote, + makeDSAReportDecision as makeReportDecision, + retrieveDSAReport, +} from "coral-server/models/dsaReport/report"; +import { isStoryArchived, retrieveStory } from "coral-server/models/story"; +import { Tenant } from "coral-server/models/tenant"; +import { rejectComment } from "coral-server/stacks"; +import { Request } from "coral-server/types/express"; + +import { + GQLDSAReportDecisionLegality, + GQLDSAReportStatus, + GQLNOTIFICATION_TYPE, + GQLREJECTION_REASON_CODE, +} from "coral-server/graph/schema/__generated__/types"; + +import { I18n } from "../i18n"; +import { InternalNotificationContext } from "../notifications/internal/context"; +import { AugmentedRedis } from "../redis"; + +export interface CreateDSAReportInput { + commentID: string; + userID: string; + lawBrokenDescription: string; + additionalInformation: string; + submissionID?: string; +} + +/** + * createDSAReport creates a new DSAReport + * + * @param mongo is the mongo context. + * @param tenant is the filtering tenant for this operation. + * @param input is the input used for creating the DSAReport + * @param now is the time this DSAReport was created + * @returns the newly created DSAReport. + */ +export async function createDSAReport( + mongo: MongoContext, + tenant: Tenant, + input: CreateDSAReportInput, + now = new Date() +) { + const result = await createReport(mongo, tenant.id, input, now); + const { dsaReport } = result; + return dsaReport; +} + +export interface AddDSAReportNoteInput { + userID: string; + body: string; + reportID: string; +} + +/** + * addDSAReportNote adds a note to the history of a DSAReport + * + * @param mongo is the mongo context. + * @param tenant is the filtering tenant for this operation. + * @param input is the input used for adding the note + * @param now is the time this note was created + * @returns the DSAReport with the new note added to its history. + */ +export async function addDSAReportNote( + mongo: MongoContext, + tenant: Tenant, + input: AddDSAReportNoteInput, + now = new Date() +) { + const result = await createReportNote(mongo, tenant.id, input, now); + const { dsaReport } = result; + return dsaReport; +} + +export interface AddDSAReportShareInput { + userID: string; + reportID: string; +} + +/** + * addDSAReportNote adds a share item to the history of a DSAReport + * + * @param mongo is the mongo context. + * @param tenant is the filtering tenant for this operation. + * @param input is the input used for adding the share item + * @param now is the time this DSAReport was shared + * @returns the DSAReport with the new share added to its history. + */ +export async function addDSAReportShare( + mongo: MongoContext, + tenant: Tenant, + input: AddDSAReportShareInput, + now = new Date() +) { + const result = await createReportShare(mongo, tenant.id, input, now); + const { dsaReport } = result; + return dsaReport; +} + +export interface DeleteDSAReportNoteInput { + id: string; + reportID: string; +} + +/** + * deleteDSAReportNote deletes a note from the history of a DSAReport + * + * @param mongo is the mongo context. + * @param tenant is the filtering tenant for this operation. + * @param input is the input used for deleting the note + * @returns the DSAReport with the note deleted from is history. + */ +export async function deleteDSAReportNote( + mongo: MongoContext, + tenant: Tenant, + input: DeleteDSAReportNoteInput, + now = new Date() +) { + const result = await deleteReportNote(mongo, tenant.id, input); + const { dsaReport } = result; + return dsaReport; +} + +export interface ChangeDSAReportStatusInput { + userID: string; + status: GQLDSAReportStatus; + reportID: string; +} + +/** + * changeDSAReportStatus changes the status of a DSAReport + * and also adds the status change to its history + * + * @param mongo is the mongo context. + * @param tenant is the filtering tenant for this operation. + * @param input is the input used for changing the status + * @returns the DSAReport with its new status + */ +export async function changeDSAReportStatus( + mongo: MongoContext, + tenant: Tenant, + input: ChangeDSAReportStatusInput, + now = new Date() +) { + const result = await changeReportStatus(mongo, tenant.id, input, now); + const { dsaReport } = result; + return dsaReport; +} + +export interface MakeDSAReportDecisionInput { + commentID: string; + commentRevisionID: string; + userID: string; + reportID: string; + legality: GQLDSAReportDecisionLegality; + legalGrounds?: string; + detailedExplanation?: string; +} + +/** + * makeDSAReportDecison makes an illegal vs legal decision for a DSAReport + * and also adds the decision to its history + * + * @param mongo is the mongo context. + * @param tenant is the filtering tenant for this operation. + * @param input is the input used for making the decision + * @returns the DSAReport with its decision + */ +export async function makeDSAReportDecision( + mongo: MongoContext, + redis: AugmentedRedis, + cache: DataCache, + config: Config, + i18n: I18n, + broker: CoralEventPublisherBroker, + notifications: InternalNotificationContext, + tenant: Tenant, + comment: Readonly | null, + input: MakeDSAReportDecisionInput, + req: Request | undefined, + now = new Date() +) { + const { + commentID, + commentRevisionID, + userID, + legalGrounds, + detailedExplanation, + reportID, + } = input; + + if (!comment) { + throw new CommentNotFoundError(commentID); + } + + const story = await retrieveStory(mongo, tenant.id, comment.storyID); + + if (!story) { + throw new StoryNotFoundError(comment.storyID); + } + + const isArchived = isStoryArchived(story); + + // REJECT if ILLEGAL + if (input.legality === GQLDSAReportDecisionLegality.ILLEGAL) { + const rejectedComment = await rejectComment( + mongo, + redis, + cache, + config, + i18n, + broker, + notifications, + tenant, + commentID, + commentRevisionID, + userID, + now, + { + code: GQLREJECTION_REASON_CODE.ILLEGAL_CONTENT, + legalGrounds, + detailedExplanation, + }, + reportID, + req, + false, // set to false because we're about to notify below + isArchived + ); + + if (rejectedComment.authorID) { + await notifications.create(tenant.id, tenant.locale, { + targetUserID: rejectedComment.authorID, + type: GQLNOTIFICATION_TYPE.ILLEGAL_REJECTED, + comment: rejectedComment, + legal: { + legality: input.legality, + grounds: legalGrounds, + explanation: detailedExplanation, + }, + }); + } + } + + const report = await retrieveDSAReport(mongo, tenant.id, reportID); + if (report) { + await notifications.create(tenant.id, tenant.locale, { + targetUserID: report.userID, + type: GQLNOTIFICATION_TYPE.DSA_REPORT_DECISION_MADE, + comment, + report, + legal: { + legality: input.legality, + grounds: legalGrounds, + explanation: detailedExplanation, + }, + }); + } + + const result = await makeReportDecision(mongo, tenant.id, input, now); + + const { dsaReport } = result; + return dsaReport; +} diff --git a/server/src/core/server/services/events/comments.ts b/server/src/core/server/services/events/comments.ts index 90a08e2438..69c8553859 100644 --- a/server/src/core/server/services/events/comments.ts +++ b/server/src/core/server/services/events/comments.ts @@ -162,12 +162,13 @@ export async function publishCommentFeatured( export async function publishModerationQueueChanges( broker: CoralEventPublisherBroker, moderationQueue: Pick, - comment: Pick + comment: Pick ) { if (moderationQueue.queues.pending === 1) { await CommentEnteredModerationQueueCoralEvent.publish(broker, { queue: GQLMODERATION_QUEUE.PENDING, commentID: comment.id, + status: comment.status, storyID: comment.storyID, siteID: comment.siteID, section: comment.section, @@ -176,6 +177,7 @@ export async function publishModerationQueueChanges( await CommentLeftModerationQueueCoralEvent.publish(broker, { queue: GQLMODERATION_QUEUE.PENDING, commentID: comment.id, + status: comment.status, storyID: comment.storyID, siteID: comment.siteID, section: comment.section, @@ -185,6 +187,7 @@ export async function publishModerationQueueChanges( await CommentEnteredModerationQueueCoralEvent.publish(broker, { queue: GQLMODERATION_QUEUE.REPORTED, commentID: comment.id, + status: comment.status, storyID: comment.storyID, siteID: comment.siteID, section: comment.section, @@ -193,6 +196,7 @@ export async function publishModerationQueueChanges( await CommentLeftModerationQueueCoralEvent.publish(broker, { queue: GQLMODERATION_QUEUE.REPORTED, commentID: comment.id, + status: comment.status, storyID: comment.storyID, siteID: comment.siteID, section: comment.section, @@ -202,6 +206,7 @@ export async function publishModerationQueueChanges( await CommentEnteredModerationQueueCoralEvent.publish(broker, { queue: GQLMODERATION_QUEUE.UNMODERATED, commentID: comment.id, + status: comment.status, storyID: comment.storyID, siteID: comment.siteID, section: comment.section, @@ -210,6 +215,7 @@ export async function publishModerationQueueChanges( await CommentLeftModerationQueueCoralEvent.publish(broker, { queue: GQLMODERATION_QUEUE.UNMODERATED, commentID: comment.id, + status: comment.status, storyID: comment.storyID, siteID: comment.siteID, section: comment.section, diff --git a/server/src/core/server/services/notifications/categories/categories.ts b/server/src/core/server/services/notifications/email/categories/categories.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/categories.ts rename to server/src/core/server/services/notifications/email/categories/categories.ts diff --git a/server/src/core/server/services/notifications/categories/category.ts b/server/src/core/server/services/notifications/email/categories/category.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/category.ts rename to server/src/core/server/services/notifications/email/categories/category.ts diff --git a/server/src/core/server/services/notifications/categories/featured.ts b/server/src/core/server/services/notifications/email/categories/featured.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/featured.ts rename to server/src/core/server/services/notifications/email/categories/featured.ts diff --git a/server/src/core/server/services/notifications/categories/index.ts b/server/src/core/server/services/notifications/email/categories/index.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/index.ts rename to server/src/core/server/services/notifications/email/categories/index.ts diff --git a/server/src/core/server/services/notifications/categories/moderation.ts b/server/src/core/server/services/notifications/email/categories/moderation.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/moderation.ts rename to server/src/core/server/services/notifications/email/categories/moderation.ts diff --git a/server/src/core/server/services/notifications/categories/reply.ts b/server/src/core/server/services/notifications/email/categories/reply.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/reply.ts rename to server/src/core/server/services/notifications/email/categories/reply.ts diff --git a/server/src/core/server/services/notifications/categories/staffReply.ts b/server/src/core/server/services/notifications/email/categories/staffReply.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/staffReply.ts rename to server/src/core/server/services/notifications/email/categories/staffReply.ts diff --git a/server/src/core/server/services/notifications/categories/unsubscribe.ts b/server/src/core/server/services/notifications/email/categories/unsubscribe.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/unsubscribe.ts rename to server/src/core/server/services/notifications/email/categories/unsubscribe.ts diff --git a/server/src/core/server/services/notifications/context.ts b/server/src/core/server/services/notifications/email/context.ts similarity index 100% rename from server/src/core/server/services/notifications/context.ts rename to server/src/core/server/services/notifications/email/context.ts diff --git a/server/src/core/server/services/notifications/notification.ts b/server/src/core/server/services/notifications/email/notification.ts similarity index 100% rename from server/src/core/server/services/notifications/notification.ts rename to server/src/core/server/services/notifications/email/notification.ts diff --git a/server/src/core/server/services/notifications/internal/context.ts b/server/src/core/server/services/notifications/internal/context.ts new file mode 100644 index 0000000000..d25c0f5652 --- /dev/null +++ b/server/src/core/server/services/notifications/internal/context.ts @@ -0,0 +1,307 @@ +import { v4 as uuid } from "uuid"; + +import { LanguageCode } from "coral-common/common/lib/helpers"; +import { MongoContext } from "coral-server/data/context"; +import { Logger } from "coral-server/logger"; +import { Comment } from "coral-server/models/comment"; +import { DSAReport } from "coral-server/models/dsaReport/report"; +import { + createNotification, + Notification, +} from "coral-server/models/notifications/notification"; +import { retrieveUser } from "coral-server/models/user"; +import { I18n } from "coral-server/services/i18n"; + +import { + GQLDSAReportDecisionLegality, + GQLNOTIFICATION_TYPE, + GQLREJECTION_REASON_CODE, +} from "coral-server/graph/schema/__generated__/types"; + +export interface DSALegality { + legality: GQLDSAReportDecisionLegality; + grounds?: string; + explanation?: string; +} + +export interface RejectionReasonInput { + code: GQLREJECTION_REASON_CODE; + legalGrounds?: string | undefined; + detailedExplanation?: string | undefined; + customReason?: string | undefined; +} + +export interface CreateNotificationInput { + targetUserID: string; + type: GQLNOTIFICATION_TYPE; + + comment?: Readonly | null; + rejectionReason?: RejectionReasonInput | null; + + report?: Readonly | null; + + legal?: DSALegality; +} + +interface CreationResult { + notification: Notification | null; + attempted: boolean; +} + +export class InternalNotificationContext { + private mongo: MongoContext; + private log: Logger; + // private i18n: I18n; + + constructor(mongo: MongoContext, i18n: I18n, log: Logger) { + this.mongo = mongo; + // this.i18n = i18n; + this.log = log; + } + + public async create( + tenantID: string, + lang: LanguageCode, + input: CreateNotificationInput + ) { + const { type, targetUserID, comment, rejectionReason, report, legal } = + input; + + const existingUser = retrieveUser(this.mongo, tenantID, targetUserID); + if (!existingUser) { + this.log.warn( + { userID: targetUserID }, + "attempted to create notification for user that does not exist, ignoring" + ); + return; + } + + const now = new Date(); + + const result: CreationResult = { + notification: null, + attempted: false, + }; + + /* + For the time being, we are not doing approved and featured + comment notifications. + */ + // if (type === GQLNOTIFICATION_TYPE.COMMENT_FEATURED && comment) { + // result.notification = await this.createFeatureCommentNotification( + // tenantID, + // type, + // targetUserID, + // comment, + // now + // ); + // result.attempted = true; + // } else if (type === GQLNOTIFICATION_TYPE.COMMENT_APPROVED && comment) { + // result.notification = await this.createApproveCommentNotification( + // tenantID, + // type, + // targetUserID, + // comment, + // now + // ); + // result.attempted = true; + // } + if (type === GQLNOTIFICATION_TYPE.COMMENT_REJECTED && comment) { + result.notification = await this.createRejectCommentNotification( + tenantID, + type, + targetUserID, + comment, + rejectionReason, + now + ); + result.attempted = true; + } else if (type === GQLNOTIFICATION_TYPE.ILLEGAL_REJECTED && comment) { + result.notification = await this.createIllegalRejectionNotification( + tenantID, + type, + targetUserID, + comment, + legal, + now + ); + result.attempted = true; + } else if ( + type === GQLNOTIFICATION_TYPE.DSA_REPORT_DECISION_MADE && + comment && + report + ) { + result.notification = await this.createDSAReportDecisionMadeNotification( + tenantID, + type, + targetUserID, + comment, + report, + legal, + now + ); + result.attempted = true; + } + + if (!result.notification && result.attempted) { + this.logCreateNotificationError(tenantID, input); + } + } + + private async createRejectCommentNotification( + tenantID: string, + type: GQLNOTIFICATION_TYPE, + targetUserID: string, + comment: Readonly, + rejectionReason?: RejectionReasonInput | null, + now = new Date() + ) { + const notification = await createNotification(this.mongo, { + id: uuid(), + tenantID, + type, + createdAt: now, + ownerID: targetUserID, + commentID: comment.id, + commentStatus: comment.status, + rejectionReason: rejectionReason?.code ?? undefined, + customReason: rejectionReason?.customReason ?? undefined, + decisionDetails: { + explanation: rejectionReason?.detailedExplanation ?? undefined, + }, + }); + + return notification; + } + + // private async createFeatureCommentNotification( + // tenantID: string, + // type: GQLNOTIFICATION_TYPE, + // targetUserID: string, + // comment: Readonly, + // now: Date + // ) { + // const notification = await createNotification(this.mongo, { + // id: uuid(), + // tenantID, + // type, + // createdAt: now, + // ownerID: targetUserID, + // commentID: comment.id, + // commentStatus: comment.status, + // }); + + // return notification; + // } + + // private async createApproveCommentNotification( + // tenantID: string, + // type: GQLNOTIFICATION_TYPE, + // targetUserID: string, + // comment: Readonly, + // now: Date + // ) { + // const notification = await createNotification(this.mongo, { + // id: uuid(), + // tenantID, + // type, + // createdAt: now, + // ownerID: targetUserID, + // commentID: comment.id, + // commentStatus: comment.status, + // }); + + // return notification; + // } + + private async createIllegalRejectionNotification( + tenantID: string, + type: GQLNOTIFICATION_TYPE, + targetUserID: string, + comment: Readonly, + legal: DSALegality | undefined, + now: Date + ) { + const notification = await createNotification(this.mongo, { + id: uuid(), + tenantID, + type, + createdAt: now, + ownerID: targetUserID, + commentID: comment.id, + commentStatus: comment.status, + rejectionReason: GQLREJECTION_REASON_CODE.ILLEGAL_CONTENT, + decisionDetails: { + legality: legal ? legal.legality : undefined, + grounds: legal ? legal.grounds : undefined, + explanation: legal ? legal.explanation : undefined, + }, + }); + + return notification; + } + + private async createDSAReportDecisionMadeNotification( + tenantID: string, + type: GQLNOTIFICATION_TYPE, + targetUserID: string, + comment: Readonly, + report: Readonly, + legal: DSALegality | undefined, + now: Date + ) { + if (!legal) { + this.log.warn( + { reportID: report.id, commentID: comment.id, targetUserID }, + "attempted to notify of DSA report decision when legality was null or undefined" + ); + return null; + } + + const notification = await createNotification(this.mongo, { + id: uuid(), + tenantID, + type, + createdAt: now, + ownerID: targetUserID, + commentID: comment.id, + commentStatus: comment.status, + reportID: report.id, + decisionDetails: { + legality: legal.legality, + grounds: legal.grounds, + explanation: legal.explanation, + }, + }); + + return notification; + } + + // private translatePhrase( + // lang: LanguageCode, + // key: string, + // text: string, + // args?: object | undefined + // ) { + // const bundle = this.i18n.getBundle(lang); + // const result = translate(bundle, text, key, args); + + // return result; + // } + + private logCreateNotificationError( + tenantID: string, + input: CreateNotificationInput + ) { + this.log.error( + { + tenantID, + userID: input.targetUserID, + commentID: input.comment ? input.comment.id : null, + reportID: input.report ? input.report.id : null, + type: input.type, + }, + "failed to create internal notification" + ); + } +} diff --git a/server/src/core/server/services/users/delete.ts b/server/src/core/server/services/users/delete.ts index c5a8ff52d0..0284eeaf2c 100644 --- a/server/src/core/server/services/users/delete.ts +++ b/server/src/core/server/services/users/delete.ts @@ -1,15 +1,24 @@ import { Collection, FilterQuery } from "mongodb"; +import { v4 as uuid } from "uuid"; import { Config } from "coral-server/config"; import { MongoContext } from "coral-server/data/context"; import { ACTION_TYPE } from "coral-server/models/action/comment"; import { Comment, getLatestRevision } from "coral-server/models/comment"; +import { DSAReport } from "coral-server/models/dsaReport"; import { Story } from "coral-server/models/story"; -import { retrieveTenant } from "coral-server/models/tenant"; +import { retrieveTenant, Tenant } from "coral-server/models/tenant"; -import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; +import { + GQLCOMMENT_STATUS, + GQLDSAReportHistoryType, + GQLDSAReportStatus, + GQLREJECTION_REASON_CODE, + GQLRejectionReason, +} from "coral-server/graph/schema/__generated__/types"; import { moderate } from "../comments/moderation"; +import { I18n, translate } from "../i18n"; import { AugmentedRedis } from "../redis"; const BATCH_SIZE = 500; @@ -33,6 +42,10 @@ interface Batch { stories: any[]; } +interface DSAReportBatch { + dsaReports: any[]; +} + async function deleteUserActionCounts( mongo: MongoContext, userID: string, @@ -117,17 +130,14 @@ async function moderateComments( mongo: MongoContext, redis: AugmentedRedis, config: Config, - tenantID: string, + i18n: I18n, + tenant: Tenant, filter: FilterQuery, targetStatus: GQLCOMMENT_STATUS, now: Date, - isArchived = false + isArchived = false, + rejectionReason?: GQLRejectionReason ) { - const tenant = await retrieveTenant(mongo, tenantID); - if (!tenant) { - throw new Error("unable to retrieve tenant"); - } - const coll = isArchived && mongo.archive ? mongo.archivedComments() : mongo.comments(); const comments = coll.find(filter); @@ -152,12 +162,14 @@ async function moderateComments( mongo, redis, config, + i18n, tenant, { commentID: comment.id, commentRevisionID: getLatestRevision(comment).id, moderatorID: null, status: targetStatus, + rejectionReason, }, now, isArchived, @@ -170,15 +182,108 @@ async function moderateComments( } } +async function updateUserDSAReports( + mongo: MongoContext, + tenantID: string, + authorID: string, + isArchived?: boolean +) { + const batch: DSAReportBatch = { + dsaReports: [], + }; + + async function processBatch() { + const dsaReports = mongo.dsaReports(); + + await executeBulkOperations(dsaReports, batch.dsaReports); + batch.dsaReports = []; + } + + const collection = + isArchived && mongo.archive ? mongo.archivedComments() : mongo.comments(); + + const cursor = collection.find({ + tenantID, + authorID, + }); + while (await cursor.hasNext()) { + const comment = await cursor.next(); + if (!comment) { + continue; + } + + const match = mongo.dsaReports().find({ + tenantID, + commentID: comment.id, + status: { + $in: [ + GQLDSAReportStatus.AWAITING_REVIEW, + GQLDSAReportStatus.UNDER_REVIEW, + ], + }, + }); + + if (!match) { + continue; + } + + const id = uuid(); + + const statusChangeHistoryItem = { + id, + createdBy: null, + createdAt: new Date(), + status: GQLDSAReportStatus.VOID, + type: GQLDSAReportHistoryType.STATUS_CHANGED, + }; + + batch.dsaReports.push({ + updateMany: { + filter: { + tenantID, + commentID: comment.id, + status: { + $in: [ + GQLDSAReportStatus.AWAITING_REVIEW, + GQLDSAReportStatus.UNDER_REVIEW, + ], + }, + }, + update: { + $set: { + status: "VOID", + }, + $push: { + history: statusChangeHistoryItem, + }, + }, + }, + }); + + if (batch.dsaReports.length >= BATCH_SIZE) { + await processBatch(); + } + } + + if (batch.dsaReports.length > 0) { + await processBatch(); + } +} + async function deleteUserComments( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, authorID: string, tenantID: string, now: Date, isArchived?: boolean ) { + const tenant = await retrieveTenant(mongo, tenantID); + if (!tenant) { + throw new Error("unable to retrieve tenant"); + } // Approve any comments that have children. // This allows the children to be visible after // the comment is deleted. @@ -186,7 +291,8 @@ async function deleteUserComments( mongo, redis, config, - tenantID, + i18n, + tenant, { tenantID, authorID, @@ -198,13 +304,21 @@ async function deleteUserComments( isArchived ); + const bundle = i18n.getBundle(tenant.locale); + const translatedExplanation = translate( + bundle, + "User account deleted", + "common-accountDeleted" + ); + // reject any comments that don't have children // This gets rid of any empty/childless deleted comments. await moderateComments( mongo, redis, config, - tenantID, + i18n, + tenant, { tenantID, authorID, @@ -220,7 +334,11 @@ async function deleteUserComments( }, GQLCOMMENT_STATUS.REJECTED, now, - isArchived + isArchived, + { + code: GQLREJECTION_REASON_CODE.OTHER, + detailedExplanation: translatedExplanation, + } ); const collection = @@ -243,9 +361,11 @@ export async function deleteUser( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, userID: string, tenantID: string, - now: Date + now: Date, + dsaEnabled: boolean ) { const user = await mongo.users().findOne({ id: userID, tenantID }); if (!user) { @@ -268,10 +388,28 @@ export async function deleteUser( await deleteUserActionCounts(mongo, userID, tenantID, true); } + // If DSA is enabled, + // Update the user's comment's associated DSAReports; set their status to VOID + if (dsaEnabled) { + await updateUserDSAReports(mongo, tenantID, userID); + if (mongo.archive) { + await updateUserDSAReports(mongo, tenantID, userID, true); + } + } + // Delete the user's comments. - await deleteUserComments(mongo, redis, config, userID, tenantID, now); + await deleteUserComments(mongo, redis, config, i18n, userID, tenantID, now); if (mongo.archive) { - await deleteUserComments(mongo, redis, config, userID, tenantID, now, true); + await deleteUserComments( + mongo, + redis, + config, + i18n, + userID, + tenantID, + now, + true + ); } // Mark the user as deleted. diff --git a/server/src/core/server/services/users/users.spec.ts b/server/src/core/server/services/users/users.spec.ts index 435df2455d..8337f0387c 100644 --- a/server/src/core/server/services/users/users.spec.ts +++ b/server/src/core/server/services/users/users.spec.ts @@ -9,6 +9,7 @@ import { } from "coral-server/test/fixtures"; import { createMockDataCache, + createMockI18n, createMockMailer, createMockMongoContex, createMockRejector, @@ -18,6 +19,7 @@ import { updateRole, updateUserBan } from "./users"; import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types"; import { demoteMember, promoteMember } from "."; +import { I18n } from "../i18n"; describe("updateUserBan", () => { afterEach(jest.clearAllMocks); @@ -35,6 +37,7 @@ describe("updateUserBan", () => { tenantID, role: GQLUSER_ROLE.ADMIN, }); + const i18n = createMockI18n("User was banned."); /* eslint-disable-next-line */ const userService = require("coral-server/models/user"); @@ -52,6 +55,7 @@ describe("updateUserBan", () => { mailer, rejector, tenant, + i18n, commenter, badUser.id, "Test message", @@ -73,6 +77,7 @@ describe("updateUserBan", () => { mailer, rejector, tenant, + new I18n("en-US"), staff, badUser.id, "Test message", @@ -99,6 +104,7 @@ describe("updateUserBan", () => { mailer, rejector, tenant, + i18n, siteAMod, badUser.id, "Test message", @@ -122,6 +128,7 @@ describe("updateUserBan", () => { mailer, rejector, tenant, + i18n, orgMod, badUser.id, "Test message", @@ -153,6 +160,7 @@ describe("updateUserBan", () => { mailer, rejector, tenant, + i18n, admin, bannedOnSiteA.id, "Test message", @@ -187,6 +195,7 @@ describe("updateUserBan", () => { mailer, rejector, tenant, + i18n, admin, bannedOnSiteB.id, "TEST MESSAGE", @@ -213,6 +222,7 @@ describe("updateUserBan", () => { mailer, rejector, tenant, + i18n, admin, notBannedUser.id, "TEST MESSAGE", @@ -239,6 +249,7 @@ describe("updateUserBan", () => { mailer, rejector, tenant, + i18n, admin, unbannedUser.id, "Test Message", @@ -255,6 +266,7 @@ describe("updateUserBan", () => { mailer, rejector, tenant, + i18n, admin, unbannedUser.id, "Test Message", diff --git a/server/src/core/server/services/users/users.ts b/server/src/core/server/services/users/users.ts index e9f6f6fac2..6858dec264 100644 --- a/server/src/core/server/services/users/users.ts +++ b/server/src/core/server/services/users/users.ts @@ -114,9 +114,11 @@ import { sendConfirmationEmail } from "coral-server/services/users/auth"; import { GQLAuthIntegrations, + GQLREJECTION_REASON_CODE, GQLUSER_ROLE, } from "coral-server/graph/schema/__generated__/types"; +import { I18n, translate } from "../i18n"; import { AugmentedRedis } from "../redis"; import { generateAdminDownloadLink, @@ -1384,6 +1386,7 @@ export async function ban( banner: User, userID: string, message: string, + i18n: I18n, rejectExistingComments: boolean, siteIDs?: string[] | null, now = new Date() @@ -1465,6 +1468,17 @@ export async function ban( let user: Readonly; + const bundle = i18n.getBundle(tenant.locale); + const tranlsatedExplanation = translate( + bundle, + "common-userBanned", + "User banned." + ); + const rejectionReason = { + code: GQLREJECTION_REASON_CODE.OTHER, + detailedExplanation: tranlsatedExplanation, + }; + // Perform a site ban if (siteIDs && siteIDs.length > 0) { user = await siteBanUser( @@ -1482,6 +1496,7 @@ export async function ban( authorID: userID, moderatorID: banner.id, siteIDs, + reason: rejectionReason, }); } const cacheAvailable = await cache.available(tenant.id); @@ -1539,6 +1554,7 @@ export async function ban( tenantID: tenant.id, authorID: userID, moderatorID: banner.id, + reason: rejectionReason, }); } } @@ -1591,6 +1607,7 @@ export async function updateUserBan( mailer: MailerQueue, rejector: RejectorQueue, tenant: Tenant, + i18n: I18n, banner: User, userID: string, message: string, @@ -1686,11 +1703,21 @@ export async function updateUserBan( // if any new bans and rejectExistingCommments, reject existing comments if (rejectExistingComments) { + const bundle = i18n.getBundle(tenant.locale); + const detailedExplanation = translate( + bundle, + "common-userBanned", + "User was banned." + ); await rejector.add({ tenantID: tenant.id, authorID: targetUser.id, moderatorID: banner.id, siteIDs: idsToBan, + reason: { + code: GQLREJECTION_REASON_CODE.OTHER, + detailedExplanation, + }, }); } diff --git a/server/src/core/server/stacks/approveComment.ts b/server/src/core/server/stacks/approveComment.ts index 58a50394f6..2e58734ec2 100644 --- a/server/src/core/server/stacks/approveComment.ts +++ b/server/src/core/server/stacks/approveComment.ts @@ -5,11 +5,16 @@ import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { getLatestRevision } from "coral-server/models/comment"; import { Tenant } from "coral-server/models/tenant"; import { moderate } from "coral-server/services/comments/moderation"; +import { I18n } from "coral-server/services/i18n"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { submitCommentAsNotSpam } from "coral-server/services/spam"; import { Request } from "coral-server/types/express"; -import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; +import { + GQLCOMMENT_STATUS, + GQLNOTIFICATION_TYPE, +} from "coral-server/graph/schema/__generated__/types"; import { publishChanges } from "./helpers"; @@ -18,7 +23,9 @@ const approveComment = async ( redis: AugmentedRedis, cache: DataCache, config: Config, + i18n: I18n, broker: CoralEventPublisherBroker, + notifications: InternalNotificationContext, tenant: Tenant, commentID: string, commentRevisionID: string, @@ -33,6 +40,7 @@ const approveComment = async ( mongo, redis, config, + i18n, tenant, { commentID, @@ -78,6 +86,12 @@ const approveComment = async ( } } + await notifications.create(tenant.id, tenant.locale, { + targetUserID: result.after.authorID!, + comment: result.after, + type: GQLNOTIFICATION_TYPE.COMMENT_APPROVED, + }); + // Return the resulting comment. return result.after; }; diff --git a/server/src/core/server/stacks/createComment.ts b/server/src/core/server/stacks/createComment.ts index 7fc240dd27..671374865e 100644 --- a/server/src/core/server/stacks/createComment.ts +++ b/server/src/core/server/stacks/createComment.ts @@ -43,7 +43,7 @@ import { import { ensureFeatureFlag, Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; import { isSiteBanned } from "coral-server/models/user/helpers"; -import { removeTag } from "coral-server/services/comments"; +import { moderate, removeTag } from "coral-server/services/comments"; import { addCommentActions, CreateAction, @@ -57,6 +57,8 @@ import { processForModeration, } from "coral-server/services/comments/pipeline"; import { WordListService } from "coral-server/services/comments/pipeline/phases/wordList/service"; +import { I18n } from "coral-server/services/i18n"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { updateUserLastCommentID } from "coral-server/services/users"; import { Request } from "coral-server/types/express"; @@ -95,7 +97,9 @@ const markCommentAsAnswered = async ( redis: AugmentedRedis, cache: DataCache, config: Config, + i18n: I18n, broker: CoralEventPublisherBroker, + notifications: InternalNotificationContext, tenant: Tenant, comment: Readonly, story: Story, @@ -144,7 +148,9 @@ const markCommentAsAnswered = async ( redis, cache, config, + i18n, broker, + notifications, tenant, comment.parentID, comment.parentRevisionID, @@ -201,7 +207,9 @@ export default async function create( wordList: WordListService, cache: DataCache, config: Config, + i18n: I18n, broker: CoralEventPublisherBroker, + notifications: InternalNotificationContext, tenant: Tenant, author: User, input: CreateComment, @@ -343,11 +351,11 @@ export default async function create( // is added, that it can already know that the comment is already in the // queue. let actionCounts: EncodedCommentActionCounts = {}; - if (result.actions.length > 0) { + if (result.commentActions.length > 0) { // Determine the unique actions, we will use this to compute the comment // action counts. This should match what is added below. actionCounts = encodeActionCounts( - ...filterDuplicateActions(result.actions) + ...filterDuplicateActions(result.commentActions) ); } @@ -386,7 +394,9 @@ export default async function create( redis, cache, config, + i18n, broker, + notifications, tenant, comment, story, @@ -419,13 +429,13 @@ export default async function create( log.trace("pushed child comment id onto parent"); } - if (result.actions.length > 0) { + if (result.commentActions.length > 0) { // Actually add the actions to the database. This will not interact with the // counts at all. await addCommentActions( mongo, tenant, - result.actions.map( + result.commentActions.map( (action): CreateAction => ({ ...action, commentID: comment.id, @@ -442,8 +452,30 @@ export default async function create( ); } + if (result.moderationAction) { + // Actually add the actions to the database. This will not interact with the + // counts at all. + await moderate( + mongo, + redis, + config, + i18n, + tenant, + { + ...result.moderationAction, + commentID: comment.id, + commentRevisionID: revision.id, + }, + now, + false, + { + actionCounts, + } + ); + } + // Update all the comment counts on stories and users. - const counts = await updateAllCommentCounts(mongo, redis, config, { + const counts = await updateAllCommentCounts(mongo, redis, config, i18n, { tenant, actionCounts, after: comment, diff --git a/server/src/core/server/stacks/editComment.ts b/server/src/core/server/stacks/editComment.ts index 78e4f559b8..dd45455dc5 100644 --- a/server/src/core/server/stacks/editComment.ts +++ b/server/src/core/server/stacks/editComment.ts @@ -17,7 +17,7 @@ import { EncodedCommentActionCounts, filterDuplicateActions, } from "coral-server/models/action/comment"; -import { createCommentModerationAction } from "coral-server/models/action/moderation/comment"; + import { editComment, EditCommentInput, @@ -34,6 +34,7 @@ import { resolveStoryMode, retrieveStory } from "coral-server/models/story"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; import { isSiteBanned } from "coral-server/models/user/helpers"; +import { moderate } from "coral-server/services/comments"; import { addCommentActions, CreateAction, @@ -44,6 +45,7 @@ import { } from "coral-server/services/comments/media"; import { processForModeration } from "coral-server/services/comments/pipeline"; import { WordListService } from "coral-server/services/comments/pipeline/phases/wordList/service"; +import { I18n } from "coral-server/services/i18n"; import { AugmentedRedis } from "coral-server/services/redis"; import { Request } from "coral-server/types/express"; @@ -83,6 +85,7 @@ export default async function edit( wordList: WordListService, cache: DataCache, config: Config, + i18n: I18n, broker: CoralEventPublisherBroker, tenant: Tenant, author: User, @@ -103,7 +106,7 @@ export default async function edit( throw new CommentNotFoundError(input.id); } - // If the original comment was a reply, then get it's parent! + // If the original comment was a reply, then get its parent! const { parentID, parentRevisionID, siteID } = originalStaleComment; const parent = await retrieveParent(mongo, tenant.id, { parentID, @@ -179,33 +182,36 @@ export default async function edit( } // Run the comment through the moderation phases. - const { body, status, metadata, actions } = await processForModeration({ - action: "EDIT", - log, - mongo, - redis, - config, - wordList, - tenant, - story, - storyMode, - parent, - comment: { - ...originalStaleComment, - ...input, - authorID: author.id, - }, - media, - author, - req, - now, - }); + const { body, status, metadata, commentActions, moderationAction } = + await processForModeration({ + action: "EDIT", + log, + mongo, + redis, + config, + wordList, + tenant, + story, + storyMode, + parent, + comment: { + ...originalStaleComment, + ...input, + authorID: author.id, + }, + media, + author, + req, + now, + }); let actionCounts: EncodedCommentActionCounts = {}; - if (actions.length > 0) { + if (commentActions.length > 0) { // Encode the new action counts that are going to be added to the new // revision. - actionCounts = encodeActionCounts(...filterDuplicateActions(actions)); + actionCounts = encodeActionCounts( + ...filterDuplicateActions(commentActions) + ); } // Perform the edit. @@ -232,11 +238,11 @@ export default async function edit( // If there were actions to insert, then insert them into the commentActions // collection. - if (actions.length > 0) { + if (commentActions.length > 0) { await addCommentActions( mongo, tenant, - actions.map( + commentActions.map( (action): CreateAction => ({ ...action, commentID: result.after.id, @@ -253,25 +259,30 @@ export default async function edit( ); } - // If the comment status changed as a result of a pipeline operation, create a - // moderation action (but don't associate it with a moderator). - if (result.before.status !== result.after.status) { - await createCommentModerationAction( + if (moderationAction) { + // Actually add the actions to the database. This will not interact with the + // counts at all. + await moderate( mongo, - tenant.id, + redis, + config, + i18n, + tenant, { - storyID: story.id, + ...moderationAction, commentID: result.after.id, - commentRevisionID: result.revision.id, - status: result.after.status, - moderatorID: null, + commentRevisionID: lastRevision.id, }, - now + now, + false, + { + actionCounts, + } ); } // Update all the comment counts on stories and users. - const counts = await updateAllCommentCounts(mongo, redis, config, { + const counts = await updateAllCommentCounts(mongo, redis, config, i18n, { tenant, actionCounts, ...result, diff --git a/server/src/core/server/stacks/helpers/retrieveParent.ts b/server/src/core/server/stacks/helpers/retrieveParent.ts index 19798d1900..b55d641053 100644 --- a/server/src/core/server/stacks/helpers/retrieveParent.ts +++ b/server/src/core/server/stacks/helpers/retrieveParent.ts @@ -5,7 +5,6 @@ import { ParentCommentRejectedError, } from "coral-server/errors"; import { - getLatestRevision, hasPublishedStatus, retrieveComment, } from "coral-server/models/comment"; @@ -31,13 +30,6 @@ async function retrieveParent( throw new CommentNotFoundError(input.parentID); } - // Check to see that the most recent revision matches the one we just replied - // to. - const revision = getLatestRevision(parent); - if (revision.id !== input.parentRevisionID) { - throw new CommentRevisionNotFoundError(parent.id, input.parentRevisionID); - } - // Check that the parent comment was visible. if (!hasPublishedStatus(parent)) { if (parent.status === GQLCOMMENT_STATUS.REJECTED) { diff --git a/server/src/core/server/stacks/helpers/updateAllCommentCounts.ts b/server/src/core/server/stacks/helpers/updateAllCommentCounts.ts index 98263e80d6..fca81158da 100644 --- a/server/src/core/server/stacks/helpers/updateAllCommentCounts.ts +++ b/server/src/core/server/stacks/helpers/updateAllCommentCounts.ts @@ -1,3 +1,4 @@ +import { getTextHTML } from "coral-server/app/handlers"; import { get, getCountRedisCacheKey } from "coral-server/app/middleware/cache"; import { Config } from "coral-server/config"; import { MongoContext } from "coral-server/data/context"; @@ -19,6 +20,7 @@ import { calculateCounts, calculateCountsDiff, } from "coral-server/services/comments/moderation"; +import { I18n } from "coral-server/services/i18n"; import { AugmentedRedis } from "coral-server/services/redis"; import { @@ -139,6 +141,7 @@ export default async function updateAllCommentCounts( mongo: MongoContext, redis: AugmentedRedis, config: Config, + i18n: I18n, input: UpdateAllCommentCountsInput, options: UpdateAllCommentCountsOptions = { updateStory: true, @@ -186,12 +189,21 @@ export default async function updateAllCommentCounts( if (entry) { const { body } = entry; - // update count in jsonp data with new total comment count + // update count and textHtml in jsonp data with new total comment count + // and matching localized textHtml const bodyArr = body.split(","); for (let i = 0; i < bodyArr.length; i++) { if (bodyArr[i].startsWith('"count":')) { bodyArr[i] = `"count":${totalCount}`; - break; + } + if (bodyArr[i].startsWith('"textHtml":')) { + const textHtml = getTextHTML( + tenant, + updatedStory.settings.mode, + i18n, + totalCount + ); + bodyArr[i] = `"textHtml":"${textHtml.replace(/"/g, '\\"')}"`; } } const updatedEntry = { diff --git a/server/src/core/server/stacks/rejectComment.ts b/server/src/core/server/stacks/rejectComment.ts index 3b98ba01e8..5955c0beae 100644 --- a/server/src/core/server/stacks/rejectComment.ts +++ b/server/src/core/server/stacks/rejectComment.ts @@ -11,12 +11,16 @@ import { import { Tenant } from "coral-server/models/tenant"; import { removeTag } from "coral-server/services/comments"; import { moderate } from "coral-server/services/comments/moderation"; +import { I18n } from "coral-server/services/i18n"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { submitCommentAsSpam } from "coral-server/services/spam"; import { Request } from "coral-server/types/express"; import { GQLCOMMENT_STATUS, + GQLNOTIFICATION_TYPE, + GQLREJECTION_REASON_CODE, GQLTAG, } from "coral-server/graph/schema/__generated__/types"; @@ -68,13 +72,24 @@ const rejectComment = async ( redis: AugmentedRedis, cache: DataCache, config: Config, + i18n: I18n, broker: CoralEventPublisherBroker | null, + notifications: InternalNotificationContext, tenant: Tenant, commentID: string, commentRevisionID: string, moderatorID: string, now: Date, - request?: Request | undefined + reason?: { + code: GQLREJECTION_REASON_CODE; + legalGrounds?: string | undefined; + detailedExplanation?: string | undefined; + customReason?: string | undefined; + }, + reportID?: string, + request?: Request | undefined, + sendNotification = true, + isArchived = false ) => { const updateAllCommentCountsArgs = { actionCounts: {}, @@ -85,15 +100,18 @@ const rejectComment = async ( mongo, redis, config, + i18n, tenant, { commentID, commentRevisionID, moderatorID, + rejectionReason: reason, status: GQLCOMMENT_STATUS.REJECTED, + reportID, }, now, - undefined, + isArchived, updateAllCommentCountsArgs ); @@ -139,7 +157,7 @@ const rejectComment = async ( // TODO: (wyattjoh) (tessalt) broker cannot easily be passed to stack from tasks, // see CORL-935 in jira - if (broker && counts) { + if (broker && counts && !isArchived) { // Publish changes to the event publisher. await publishChanges(broker, { ...updateStatus, @@ -149,6 +167,18 @@ const rejectComment = async ( }); } + if ( + sendNotification && + !(reason?.code === GQLREJECTION_REASON_CODE.BANNED_WORD) + ) { + await notifications.create(tenant.id, tenant.locale, { + targetUserID: result.after.authorID!, + comment: result.after, + rejectionReason: reason, + type: GQLNOTIFICATION_TYPE.COMMENT_REJECTED, + }); + } + // Return the resulting comment. return rollingResult; }; diff --git a/server/src/core/server/test/fixtures.ts b/server/src/core/server/test/fixtures.ts index 65ce9cea59..b27fa72372 100644 --- a/server/src/core/server/test/fixtures.ts +++ b/server/src/core/server/test/fixtures.ts @@ -11,6 +11,7 @@ import { Token, User } from "coral-server/models/user"; import { GQLCOMMENT_STATUS, GQLDIGEST_FREQUENCY, + GQLDSA_METHOD_OF_REDRESS, GQLMODERATION_MODE, GQLUSER_ROLE, } from "coral-server/graph/schema/__generated__/types"; @@ -184,6 +185,16 @@ export const createTenantFixture = ( flattenReplies: false, disableDefaultFonts: false, emailDomainModeration: [], + dsa: { + enabled: false, + methodOfRedress: { + method: GQLDSA_METHOD_OF_REDRESS.NONE, + }, + }, + embeddedComments: { + allowReplies: true, + oEmbedAllowedOrigins: [], + }, }; return merge(fixture, defaults); diff --git a/server/src/core/server/test/mocks.ts b/server/src/core/server/test/mocks.ts index ca9b485202..c263ade2d2 100644 --- a/server/src/core/server/test/mocks.ts +++ b/server/src/core/server/test/mocks.ts @@ -2,8 +2,9 @@ import { DataCache } from "coral-server/data/cache/dataCache"; import { MongoContext } from "coral-server/data/context"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { RejectorQueue } from "coral-server/queue/tasks/rejector"; +import { I18n } from "coral-server/services/i18n"; +import { AugmentedRedis } from "coral-server/services/redis"; import { TenantCache } from "coral-server/services/tenant/cache"; -import { Redis } from "ioredis"; const createMockCollection = () => ({ findOneAndUpdate: jest.fn(), @@ -11,17 +12,20 @@ const createMockCollection = () => ({ }); export const createMockMongoContex = () => { + const comments = createMockCollection(); const users = createMockCollection(); return { ctx: { + comments: () => comments, users: () => users, } as unknown as MongoContext, + comments, users, }; }; -export const createMockRedis = () => ({} as Redis); +export const createMockRedis = () => ({} as AugmentedRedis); export const createMockTenantCache = (): TenantCache => ({ @@ -42,3 +46,15 @@ export const createMockRejector = () => ({ add: jest.fn().mockResolvedValue({}), } as unknown as RejectorQueue); + +export const createMockI18n = (value: string) => + ({ + getBundle: () => { + return { + getMessage: () => { + return { value }; + }, + formatPattern: () => {}, + }; + }, + } as unknown as I18n);