diff --git a/package-lock.json b/package-lock.json index 87ed024ab..9ced80ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1647,9 +1647,9 @@ } }, "@types/bson": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@types/bson/-/bson-1.0.10.tgz", - "integrity": "sha512-gRf+Qy5Qiyjz28ZkPRP37bDHtGG67op/lV2qcIMhWUq4vIMJ6/j13ajeYH7LFhJ5RNflyLHmdANPGDXZ5a8EzQ==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/bson/-/bson-1.0.11.tgz", + "integrity": "sha512-j+UcCWI+FsbI5/FQP/Kj2CXyplWAz39ktHFkXk84h7dNblKRSoNJs95PZFRd96NQGqsPEPgeclqnznWZr14ZDA==", "dev": true, "requires": { "@types/node": "*" @@ -1989,9 +1989,9 @@ } }, "@types/mongodb": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.1.1.tgz", - "integrity": "sha512-lHEH+OwYNeuC28jlmdPT/wBAVMuB6M1sHjZKAtaho/LeJf78ILJPYUq2OD7mWVj9QR7uYiKVt4ExGkXegHFCJQ==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.1.7.tgz", + "integrity": "sha512-ljS4mE9o3apEkI59pftdnLf3b1ZczMPtXWp1myrhR+E/CLk0O5SjbTt6Rn3OxMB+Qc2eAytrQVufa4y1pFqE2A==", "dev": true, "requires": { "@types/bson": "*", @@ -10222,8 +10222,11 @@ "resolved": "https://registry.npmjs.org/fluent-intl-polyfill/-/fluent-intl-polyfill-0.1.0.tgz", "integrity": "sha1-ETOUSrJHeINHOZVZaIPg05z4hc8=", "dev": true, - "requires": { - "intl-pluralrules": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b" + "dependencies": { + "intl-pluralrules": { + "version": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b", + "from": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b" + } } }, "fluent-langneg": { @@ -12667,11 +12670,6 @@ "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=", "dev": true }, - "intl-pluralrules": { - "version": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b", - "from": "github:projectfluent/IntlPluralRules#module", - "dev": true - }, "invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", diff --git a/package.json b/package.json index 5a29bc959..ce5efe0c4 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "@types/lodash": "^4.14.111", "@types/luxon": "^0.5.3", "@types/mini-css-extract-plugin": "^0.2.0", - "@types/mongodb": "^3.1.1", + "@types/mongodb": "^3.1.7", "@types/ms": "^0.7.30", "@types/node": "^10.5.2", "@types/node-fetch": "^2.1.2", diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 111a40cd4..23b0da93b 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -6,6 +6,11 @@ import { CommentToRepliesArgs, GQLCOMMENT_SORT, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + ACTION_ITEM_TYPE, + ActionCounts, + retrieveManyAuthoredActionCounts, +} from "talk-server/models/actions"; import { retrieveCommentAssetConnection, retrieveCommentRepliesConnection, @@ -16,6 +21,17 @@ export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => retrieveManyComments(ctx.mongo, ctx.tenant.id, ids) ), + retrieveAuthoredActionCounts: new DataLoader( + (itemIDs: string[]) => + retrieveManyAuthoredActionCounts( + ctx.mongo, + ctx.tenant.id, + // This should only ever be accessed when a user is logged in. + ctx.user!.id, + ACTION_ITEM_TYPE.COMMENTS, + itemIDs + ) + ), forAsset: ( assetID: string, // Apply the graph schema defaults at the loader. diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts index ee775ff0f..ef6dcb2cf 100644 --- a/src/core/server/graph/tenant/resolvers/comment.ts +++ b/src/core/server/graph/tenant/resolvers/comment.ts @@ -1,4 +1,5 @@ import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { decodeActionCounts } from "talk-server/models/actions"; import { Comment } from "talk-server/models/comment"; const Comment: GQLCommentTypeResolver = { @@ -17,6 +18,9 @@ const Comment: GQLCommentTypeResolver = { ctx.loaders.Users.user.load(comment.author_id), replies: (comment, input, ctx) => ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input), + actionCounts: comment => decodeActionCounts(comment.action_counts), + authoredActionCounts: (comment, input, ctx) => + ctx.loaders.Comments.retrieveAuthoredActionCounts.load(comment.id), }; export default Comment; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 10fa82564..af8169138 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -210,6 +210,37 @@ type CommentDontAgreeAction implements CommentAction { createdAt: Time! } +type ReactionActionCounts { + total: Int! +} + +type DontAgreeActionCounts { + total: Int! +} + +type FlagReasonActionCounts { + COMMENT_REPORTED_OFFENSIVE: Int! + COMMENT_REPORTED_SPAM: Int! + COMMENT_DETECTED_TOXIC: Int! + COMMENT_DETECTED_SPAM: Int! + COMMENT_DETECTED_BODY_COUNT: Int! + COMMENT_DETECTED_TRUST: Int! + COMMENT_DETECTED_LINKS: Int! + COMMENT_DETECTED_BANNED_WORD: Int! + COMMENT_DETECTED_SUSPECT_WORD: Int! +} + +type FlagActionCounts { + total: Int! + reasons: FlagReasonActionCounts! +} + +type ActionCounts { + reaction: ReactionActionCounts! + dontagree: DontAgreeActionCounts! + flag: FlagActionCounts! +} + ################################################################################ ## Settings ################################################################################ @@ -829,6 +860,17 @@ type Comment { editing returns details about the edit status of a Comment. """ editing: EditInfo! + + """ + actionCounts stores the counts of all the actions for the Comment. + """ + actionCounts: ActionCounts! + + """ + authoredActionCounts stores the counts of all the actions for the Comment as + it is written by the current User. + """ + authoredActionCounts: ActionCounts! @auth } type PageInfo { @@ -996,7 +1038,7 @@ type Query { """ me is the current logged in User. """ - me: User + me: User @auth """ settings is the Settings for a given Tenant. diff --git a/src/core/server/graph/tenant/schema/schema.spec.ts b/src/core/server/graph/tenant/schema/schema.spec.ts new file mode 100644 index 000000000..f3aea0007 --- /dev/null +++ b/src/core/server/graph/tenant/schema/schema.spec.ts @@ -0,0 +1,24 @@ +import { + GQLCOMMENT_FLAG_REASON, + GQLFlagReasonActionCounts, +} from "talk-server/graph/tenant/schema/__generated__/types"; + +type ExtractKeys = { [P in keyof T]: P }[keyof T]; +type A = ExtractKeys; +type B = ExtractKeys; + +// These tests ensure that the enums contained in GQLCOMMENT_FLAG_REASON are +// also defined on the GQLFlagReasonActionCounts type. +describe("GQLFlagReasonActionCounts", () => { + it("contains all the flag enum types", () => { + const a: A = "" as any; + let b: B = "" as any; + b = a; + expect(b).toBe(""); + + let c: A = "" as any; + const d: B = "" as any; + c = d; + expect(c).toBe(""); + }); +}); diff --git a/src/core/server/models/actions.ts b/src/core/server/models/actions.ts index c8094d9ca..acc6f150e 100644 --- a/src/core/server/models/actions.ts +++ b/src/core/server/models/actions.ts @@ -193,6 +193,40 @@ export async function createActions( return Promise.all(inputs.map(input => createAction(mongo, tenantID, input))); } +/** + * retrieveManyAuthoredActionCounts returns the action counts for a specific + * user. + */ +export async function retrieveManyAuthoredActionCounts( + mongo: Db, + tenantID: string, + userID: string | null, + itemType: ACTION_ITEM_TYPE, + itemIDs: string[] +) { + const cursor = await collection(mongo).find({ + tenant_id: tenantID, + user_id: userID, + item_type: itemType, + item_id: { $in: itemIDs }, + }); + + const actions = await cursor.toArray(); + + // For each of the actions returned by the query, group the actions by the + // item id. Then compute the action counts for each of these action groups, + // which can be used to determine existence of actions. + return itemIDs + .map(itemID => actions.filter(action => action.item_id === itemID)) + .map(itemActions => + itemActions.reduce( + (actionCounts, { action_type, reason }) => + incrementActionCounts(actionCounts, action_type, reason), + createEmptyActionCounts() + ) + ); +} + export type DeleteActionInput = Pick< Action, "action_type" | "item_type" | "item_id" | "reason" | "user_id" @@ -349,16 +383,10 @@ function decodeActionCountKey(key: string): DecodedActionCountKey { } /** - * decodeActionCounts will take the encoded action counts and decode them into - * a useable format. - * - * @param encodedActionCounts the action counts to decode + * createEmptyActionCounts creates a default/empty set of action counts. */ -export function decodeActionCounts( - encodedActionCounts: EncodedActionCounts -): ActionCounts { - // Default all the action counts to zero. - const actionCounts: ActionCounts = { +function createEmptyActionCounts(): ActionCounts { + return { [ACTION_TYPE.REACTION]: { total: 0, }, @@ -376,35 +404,59 @@ export function decodeActionCounts( ) as Record, }, }; +} + +/** + * decodeActionCounts will take the encoded action counts and decode them into + * a useable format. + * + * @param encodedActionCounts the action counts to decode + */ +export function decodeActionCounts( + encodedActionCounts: EncodedActionCounts +): ActionCounts { + // Default all the action counts to zero. + const actionCounts: ActionCounts = createEmptyActionCounts(); // Loop over all the encoded action counts to extract each of the action // counts as they are encoded. - Object.entries(encodedActionCounts).forEach(([key, count]) => { + Object.entries(encodedActionCounts).map(([key, count]) => { // Pull out the action type and the reason from the key. const { actionType, reason } = decodeActionCountKey(key); // Handle the different types and reasons. - switch (actionType) { - case ACTION_TYPE.REACTION: - actionCounts[ACTION_TYPE.REACTION].total += count; - break; - case ACTION_TYPE.DONT_AGREE: - actionCounts[ACTION_TYPE.DONT_AGREE].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 - // just got the updated reason. - if (reason) { - actionCounts[ACTION_TYPE.FLAG].reasons[reason] += count; - } else { - actionCounts[ACTION_TYPE.FLAG].total += count; - } - break; - default: - throw new Error("unexpected action type"); - } + incrementActionCounts(actionCounts, actionType, reason, count); }); return actionCounts; } + +function incrementActionCounts( + actionCounts: ActionCounts, + actionType: ACTION_TYPE, + reason: GQLCOMMENT_FLAG_REASON | undefined, + count: number = 1 +) { + switch (actionType) { + case ACTION_TYPE.REACTION: + actionCounts[ACTION_TYPE.REACTION].total += count; + break; + case ACTION_TYPE.DONT_AGREE: + actionCounts[ACTION_TYPE.DONT_AGREE].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 + // just got the updated reason. + if (reason) { + actionCounts[ACTION_TYPE.FLAG].reasons[reason] += count; + } else { + actionCounts[ACTION_TYPE.FLAG].total += count; + } + break; + default: + throw new Error("unexpected action type"); + } + + return actionCounts; +}